diff --git a/ietf/bin/merge-person-records b/ietf/bin/merge-person-records index c922fd2f7..1a020ecd1 100755 --- a/ietf/bin/merge-person-records +++ b/ietf/bin/merge-person-records @@ -12,11 +12,10 @@ import django django.setup() import argparse -import pprint -from django.contrib import admin -from django.contrib.auth.models import User from ietf.person.models import Person +from ietf.person.utils import merge_persons + parser = argparse.ArgumentParser() parser.add_argument("source_id",type=int) parser.add_argument("target_id",type=int) @@ -30,62 +29,4 @@ response = raw_input('Ok to continue y/n? ') if response.lower() != 'y': sys.exit() -# merge emails -for email in source.email_set.all(): - print "Merging email: {}".format(email.address) - email.person = target - email.save() - -# merge aliases -target_aliases = [ a.name for a in target.alias_set.all() ] -for alias in source.alias_set.all(): - if alias.name in target_aliases: - alias.delete() - else: - print "Merging alias: {}".format(alias.name) - alias.person = target - alias.save() - -# merge DocEvents -for docevent in source.docevent_set.all(): - docevent.by = target - docevent.save() - -# merge SubmissionEvents -for subevent in source.submissionevent_set.all(): - subevent.by = target - subevent.save() - -# merge Messages -for message in source.message_set.all(): - message.by = target - message.save() - -# merge Constraints -for constraint in source.constraint_set.all(): - constraint.person = target - constraint.save() - -# merge Roles -for role in source.role_set.all(): - role.person = target - role.save() - -# check for any remaining relationships and delete if none -objs = [source] -opts = Person._meta -user = User.objects.filter(is_superuser=True).first() -admin_site = admin.site -using = 'default' - -deletable_objects, perms_needed, protected = admin.utils.get_deleted_objects( - objs, opts, user, admin_site, using) - -if len(deletable_objects) > 1: - print "Not Deleting Person: {}({})".format(source.ascii,source.pk) - print "Related objects remain:" - pprint.pprint(deletable_objects[1]) - -else: - print "Deleting Person: {}({})".format(source.ascii,source.pk) - source.delete() +merge_persons(source,target,sys.stdout) diff --git a/ietf/dbtemplate/factories.py b/ietf/dbtemplate/factories.py new file mode 100644 index 000000000..36a956dba --- /dev/null +++ b/ietf/dbtemplate/factories.py @@ -0,0 +1,8 @@ +import factory + +from ietf.dbtemplate.models import DBTemplate + +class DBTemplateFactory(factory.DjangoModelFactory): + class Meta: + model = DBTemplate + diff --git a/ietf/dbtemplate/fixtures/nomcom_templates.xml b/ietf/dbtemplate/fixtures/nomcom_templates.xml index 1059d7073..cc0eb32c6 100644 --- a/ietf/dbtemplate/fixtures/nomcom_templates.xml +++ b/ietf/dbtemplate/fixtures/nomcom_templates.xml @@ -76,10 +76,10 @@ Questionnaire - /nomcom/defaults/position/requirements.txt + /nomcom/defaults/position/requirements Position requirements $position: Position - plain + rst These are the requirements for the position $position: Requirements. diff --git a/ietf/dbtemplate/templates/dbtemplate/template_show.html b/ietf/dbtemplate/templates/dbtemplate/template_show.html new file mode 100644 index 000000000..7c6fbb5e7 --- /dev/null +++ b/ietf/dbtemplate/templates/dbtemplate/template_show.html @@ -0,0 +1,40 @@ +{% extends "ietf.html" %} + +{% load bootstrap3 %} + +{% block content %} +

Template: {{ template }}

+ +

Meta information

+
+
Title
+
{{ template.title }} +
Group
+
{{ template.group }}
+
Template type
+
{{ template.type.name }} + {% if template.type.slug == "rst" %} +

This template uses the syntax of reStructuredText. Get a quick reference at http://docutils.sourceforge.net/docs/user/rst/quickref.html.

+

You can do variable interpolation with $varialbe if the template allows any variable.

+ {% endif %} + {% if template.type.slug == "django" %} +

This template uses the syntax of the default django template framework. Get more info at https://docs.djangoproject.com/en/dev/topics/templates/.

+

You can do variable interpolation with the current django markup {{variable}} if the template allows any variable.

+ {% endif %} + {% if template.type.slug == "plain" %} +

This template uses plain text, so no markup is used. You can do variable interpolation with $variable if the template allows any variable.

+ {% endif %} +
+ {% if template.variables %} +
Variables allowed in this template
+
{{ template.variables|linebreaks }}
+ {% endif %} +
+ +

Template content

+ +
+

{{ template.content }}

+
+ +{% endblock content %} diff --git a/ietf/dbtemplate/views.py b/ietf/dbtemplate/views.py index 6986fffdc..ec2a9bf3a 100644 --- a/ietf/dbtemplate/views.py +++ b/ietf/dbtemplate/views.py @@ -43,3 +43,19 @@ def template_edit(request, acronym, template_id, base_template='dbtemplate/templ } context.update(extra_context) return render(request, base_template, context) + +def template_show(request, acronym, template_id, base_template='dbtemplate/template_edit.html', extra_context=None): + group = get_object_or_404(Group, acronym=acronym) + chairs = group.role_set.filter(name__slug='chair') + extra_context = extra_context or {} + + if not has_role(request.user, "Secretariat") and not chairs.filter(person__user=request.user).count(): + return HttpResponseForbidden("You are not authorized to access this view") + + template = get_object_or_404(DBTemplate, id=template_id, group=group) + + context = {'template': template, + 'group': group, + } + context.update(extra_context) + return render(request, base_template, context) diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index 18372d313..a969554d2 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -1302,6 +1302,7 @@ class ChangeReplacesTests(TestCase): expires=datetime.datetime.now() + datetime.timedelta(days=settings.INTERNET_DRAFT_DAYS_TO_EXPIRE), group=mars_wg, ) + self.basea.documentauthor_set.create(author=Email.objects.create(address="basea_author@example.com"),order=1) self.baseb = Document.objects.create( name="draft-test-base-b", @@ -1312,6 +1313,7 @@ class ChangeReplacesTests(TestCase): expires=datetime.datetime.now() - datetime.timedelta(days = 365 - settings.INTERNET_DRAFT_DAYS_TO_EXPIRE), group=mars_wg, ) + self.baseb.documentauthor_set.create(author=Email.objects.create(address="baseb_author@example.com"),order=1) self.replacea = Document.objects.create( name="draft-test-replace-a", @@ -1322,6 +1324,7 @@ class ChangeReplacesTests(TestCase): expires=datetime.datetime.now() + datetime.timedelta(days = settings.INTERNET_DRAFT_DAYS_TO_EXPIRE), group=mars_wg, ) + self.replacea.documentauthor_set.create(author=Email.objects.create(address="replacea_author@example.com"),order=1) self.replaceboth = Document.objects.create( name="draft-test-replace-both", @@ -1332,6 +1335,7 @@ class ChangeReplacesTests(TestCase): expires=datetime.datetime.now() + datetime.timedelta(days = settings.INTERNET_DRAFT_DAYS_TO_EXPIRE), group=mars_wg, ) + self.replaceboth.documentauthor_set.create(author=Email.objects.create(address="replaceboth_author@example.com"),order=1) self.basea.set_state(State.objects.get(used=True, type="draft", slug="active")) self.baseb.set_state(State.objects.get(used=True, type="draft", slug="expired")) @@ -1366,8 +1370,8 @@ class ChangeReplacesTests(TestCase): self.assertTrue(not RelatedDocument.objects.filter(relationship='possibly-replaces', source=self.replacea)) self.assertEqual(len(outbox), 1) self.assertTrue('replacement status updated' in outbox[-1]['Subject']) - self.assertTrue('base-a@' in outbox[-1]['To']) - self.assertTrue('replace-a@' in outbox[-1]['To']) + self.assertTrue('replacea_author@' in outbox[-1]['To']) + self.assertTrue('basea_author@' in outbox[-1]['To']) empty_outbox() # Post that says replaceboth replaces both base a and base b @@ -1378,9 +1382,9 @@ class ChangeReplacesTests(TestCase): self.assertEqual(Document.objects.get(name='draft-test-base-a').get_state().slug,'repl') self.assertEqual(Document.objects.get(name='draft-test-base-b').get_state().slug,'repl') self.assertEqual(len(outbox), 1) - self.assertTrue('base-a@' in outbox[-1]['To']) - self.assertTrue('base-b@' in outbox[-1]['To']) - self.assertTrue('replace-both@' in outbox[-1]['To']) + self.assertTrue('basea_author@' in outbox[-1]['To']) + self.assertTrue('baseb_author@' in outbox[-1]['To']) + self.assertTrue('replaceboth_author@' in outbox[-1]['To']) # Post that undoes replaceboth empty_outbox() @@ -1389,9 +1393,9 @@ class ChangeReplacesTests(TestCase): self.assertEqual(Document.objects.get(name='draft-test-base-a').get_state().slug,'repl') # Because A is still also replaced by replacea self.assertEqual(Document.objects.get(name='draft-test-base-b').get_state().slug,'expired') self.assertEqual(len(outbox), 1) - self.assertTrue('base-a@' in outbox[-1]['To']) - self.assertTrue('base-b@' in outbox[-1]['To']) - self.assertTrue('replace-both@' in outbox[-1]['To']) + self.assertTrue('basea_author@' in outbox[-1]['To']) + self.assertTrue('baseb_author@' in outbox[-1]['To']) + self.assertTrue('replaceboth_author@' in outbox[-1]['To']) # Post that undoes replacea empty_outbox() @@ -1399,8 +1403,8 @@ class ChangeReplacesTests(TestCase): r = self.client.post(url, dict(replaces="")) self.assertEqual(r.status_code, 302) self.assertEqual(Document.objects.get(name='draft-test-base-a').get_state().slug,'active') - self.assertTrue('base-a@' in outbox[-1]['To']) - self.assertTrue('replace-a@' in outbox[-1]['To']) + self.assertTrue('basea_author@' in outbox[-1]['To']) + self.assertTrue('replacea_author@' in outbox[-1]['To']) def test_review_possibly_replaces(self): diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 575fa2c44..63bc52276 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -20,6 +20,8 @@ from ietf.utils import draft, markup_txt from ietf.utils.mail import send_mail from ietf.mailtrigger.utils import gather_address_lists +import debug # pyflakes:ignore + #TODO FIXME - it would be better if this lived in ietf/doc/mails.py, but there's # an import order issue to work out. def email_update_telechat(request, doc, text): diff --git a/ietf/group/factories.py b/ietf/group/factories.py new file mode 100644 index 000000000..9cef50afd --- /dev/null +++ b/ietf/group/factories.py @@ -0,0 +1,10 @@ +import factory + +from ietf.group.models import Group + +class GroupFactory(factory.DjangoModelFactory): + class Meta: + model = Group + + name = factory.Faker('sentence',nb_words=6) + acronym = factory.Faker('word') diff --git a/ietf/ietfauth/utils.py b/ietf/ietfauth/utils.py index 2a1db3938..e28587aec 100644 --- a/ietf/ietfauth/utils.py +++ b/ietf/ietfauth/utils.py @@ -64,9 +64,9 @@ def has_role(user, role_names, *args, **kwargs): "RG Secretary": Q(person=person,name="secr", group__type="rg", group__state__in=["active","proposed"]), "AG Secretary": Q(person=person,name="secr", group__type="ag", group__state__in=["active"]), "Team Chair": Q(person=person,name="chair", group__type="team", group__state="active"), - "Nomcom Chair": Q(person=person, name="chair", group__type="nomcom", group__state="active", group__acronym__icontains=kwargs.get('year', '0000')), - "Nomcom Advisor": Q(person=person, name="advisor", group__type="nomcom", group__state="active", group__acronym__icontains=kwargs.get('year', '0000')), - "Nomcom": Q(person=person, group__type="nomcom", group__state="active", group__acronym__icontains=kwargs.get('year', '0000')), + "Nomcom Chair": Q(person=person, name="chair", group__type="nomcom", group__acronym__icontains=kwargs.get('year', '0000')), + "Nomcom Advisor": Q(person=person, name="advisor", group__type="nomcom", group__acronym__icontains=kwargs.get('year', '0000')), + "Nomcom": Q(person=person, group__type="nomcom", group__acronym__icontains=kwargs.get('year', '0000')), "Liaison Manager": Q(person=person,name="liaiman",group__type="sdo",group__state="active", ), "Authorized Individual": Q(person=person,name="auth",group__type="sdo",group__state="active", ), } diff --git a/ietf/mailtrigger/migrations/0003_merge_request_trigger.py b/ietf/mailtrigger/migrations/0003_merge_request_trigger.py new file mode 100644 index 000000000..cbb8e5a62 --- /dev/null +++ b/ietf/mailtrigger/migrations/0003_merge_request_trigger.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + +def forward(apps, schema_editor): + + Recipient=apps.get_model('mailtrigger','Recipient') + MailTrigger=apps.get_model('mailtrigger','MailTrigger') + + m = MailTrigger.objects.create( + slug='person_merge_requested', + desc="Recipients for a message requesting that duplicated Person records be merged ") + m.to = Recipient.objects.filter(slug__in=['ietf_secretariat', ]) + +def reverse(apps, schema_editor): + MailTrigger=apps.get_model('mailtrigger','MailTrigger') + MailTrigger.objects.filter(slug='person_merge_requested').delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('mailtrigger', '0002_auto_20150809_1314'), + ] + + operations = [ + migrations.RunPython(forward, reverse) + ] diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index e270b992f..2f7279e41 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -648,6 +648,7 @@ { "fields": { "order": 0, + "prefix": "charter", "used": true, "name": "Charter", "desc": "" @@ -658,6 +659,7 @@ { "fields": { "order": 0, + "prefix": "agenda", "used": true, "name": "Agenda", "desc": "" @@ -668,6 +670,7 @@ { "fields": { "order": 0, + "prefix": "minutes", "used": true, "name": "Minutes", "desc": "" @@ -678,6 +681,7 @@ { "fields": { "order": 0, + "prefix": "slides", "used": true, "name": "Slides", "desc": "" @@ -688,6 +692,7 @@ { "fields": { "order": 0, + "prefix": "draft", "used": true, "name": "Draft", "desc": "" @@ -698,6 +703,7 @@ { "fields": { "order": 0, + "prefix": "liai-att", "used": true, "name": "Liaison Attachment", "desc": "" @@ -708,6 +714,7 @@ { "fields": { "order": 0, + "prefix": "conflict-review", "used": true, "name": "Conflict Review", "desc": "" @@ -718,6 +725,7 @@ { "fields": { "order": 0, + "prefix": "status-change", "used": true, "name": "Status Change", "desc": "" @@ -728,6 +736,7 @@ { "fields": { "order": 0, + "prefix": "", "used": false, "name": "Shepherd's writeup", "desc": "" @@ -738,6 +747,7 @@ { "fields": { "order": 0, + "prefix": "", "used": false, "name": "Liaison", "desc": "" @@ -748,6 +758,7 @@ { "fields": { "order": 0, + "prefix": "recording", "used": true, "name": "Recording", "desc": "" @@ -758,6 +769,7 @@ { "fields": { "order": 0, + "prefix": "bluesheets", "used": true, "name": "Bluesheets", "desc": "" @@ -4551,6 +4563,14 @@ "model": "mailtrigger.recipient", "pk": "doc_authors" }, +{ + "fields": { + "template": "{{doc.author_list}}", + "desc": "The authors of the document, without using the draft aliases" + }, + "model": "mailtrigger.recipient", + "pk": "doc_authors_expanded" +}, { "fields": { "template": null, @@ -4711,6 +4731,14 @@ "model": "mailtrigger.recipient", "pk": "iana_last_call" }, +{ + "fields": { + "template": "", + "desc": "The I-D-Announce Email List" + }, + "model": "mailtrigger.recipient", + "pk": "id_announce" +}, { "fields": { "template": "The IESG ", @@ -5316,11 +5344,7 @@ "fields": { "cc": [], "to": [ - "doc_authors", - "doc_group_chairs", - "doc_group_responsible_directors", - "doc_notify", - "doc_shepherd" + "doc_authors_expanded" ], "desc": "Recipients when what a document replaces or is replaced by changes" }, @@ -5708,6 +5732,17 @@ "model": "mailtrigger.mailtrigger", "pk": "nomination_received" }, +{ + "fields": { + "cc": [], + "to": [ + "ietf_secretariat" + ], + "desc": "Recipients for a message requesting that duplicated Person records be merged " + }, + "model": "mailtrigger.mailtrigger", + "pk": "person_merge_requested" +}, { "fields": { "cc": [ @@ -5853,7 +5888,7 @@ "submission_group_mail_list" ], "to": [ - "ietf_announce" + "id_announce" ], "desc": "Recipients for the announcement of a successfully submitted draft" }, diff --git a/ietf/nomcom/admin.py b/ietf/nomcom/admin.py index 1c8b35d9f..21a4b59d9 100644 --- a/ietf/nomcom/admin.py +++ b/ietf/nomcom/admin.py @@ -23,7 +23,7 @@ class NomineePositionAdmin(admin.ModelAdmin): class PositionAdmin(admin.ModelAdmin): - list_display = ('name', 'nomcom', 'is_open', 'incumbent') + list_display = ('name', 'nomcom', 'is_open') list_filter = ('nomcom',) diff --git a/ietf/nomcom/factories.py b/ietf/nomcom/factories.py new file mode 100644 index 000000000..c2059acfa --- /dev/null +++ b/ietf/nomcom/factories.py @@ -0,0 +1,147 @@ +import factory +import random + +from ietf.nomcom.models import NomCom, Position, Feedback, Nominee, NomineePosition +from ietf.group.factories import GroupFactory +from ietf.person.factories import PersonFactory + +import debug # pyflakes:ignore + +cert = '''-----BEGIN CERTIFICATE----- +MIIDHjCCAgagAwIBAgIJAKDCCjbQboJzMA0GCSqGSIb3DQEBCwUAMBMxETAPBgNV +BAMMCE5vbUNvbTE1MB4XDTE0MDQwNDIxMTQxNFoXDTE2MDQwMzIxMTQxNFowEzER +MA8GA1UEAwwITm9tQ29tMTUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQC2QXCsAitYSOgPYor77zQnEeHuVqlcuhpH1wpKB+N6WcScA5N3AnX9uZEFOt6M +cJ+MCiHECdqDlH6npQTJlpCpIVgAD4B6xzjRBRww8d3lClA/kKwsKzuX93RS0Uv3 +0hAD6q9wjqK/m6vR5Y1SsvJYV0y+Yu5j9xUEsojMH7O3NlXWAYOb6oH+f/X7PX27 +IhtiCwfICMmVWh/hKeXuFx6HSOcH3gZ6Tlk1llfDbE/ArpsZ6JmnLn73+64yqIoO +ZOc4JJUPrdsmbNwXoxQSQhrpwjN8NpSkQaJbHGB3G+OWvP4fpqcweFHxlEq1Hhef +uR9E6jc3qwxVQfwjbcq6N/4JAgMBAAGjdTBzMB0GA1UdDgQWBBTJow+TJynRWsTQ +LzoS861FGb/rxDAOBgNVHQ8BAf8EBAMCBLAwDwYDVR0TAQH/BAUwAwEB/zAcBgNV +HREEFTATgRFub21jb20xNUBpZXRmLm9yZzATBgNVHSUEDDAKBggrBgEFBQcDBDAN +BgkqhkiG9w0BAQsFAAOCAQEAJwLapB9u5N3iK6SCTqh+PVkigZeB2YMVBW8WA3Ut +iRPBj+jHWOpF5pzZHTOcNaAxDEG9lyIlcWqc93A24K/Gen11Tx0hO4FAPOG0+PP8 +4lx7F6xeeyUNR44pInrB93G2q0jl+3wjZH8uhBKlGji4UTMpDPpEl6uiyQCbkMMm +Vr7HZH5Dv/lsjGHHf8uJO7+mcMh+tqxLn3DzPrm61OfeWdkoVX2pTz0imRQ3Es+8 +I7zNMk+fNNaEEyPnEyHfuWq0uD/qKeP27NZIoINy6E3INQ5QaE2uc1nQULg5y7uJ +toX3j+FUe2UiUak3ACXdrOPSsFP0KRrFwuMnuHHXkGj/Uw== +-----END CERTIFICATE----- +''' + +key = '''-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC2QXCsAitYSOgP +Yor77zQnEeHuVqlcuhpH1wpKB+N6WcScA5N3AnX9uZEFOt6McJ+MCiHECdqDlH6n +pQTJlpCpIVgAD4B6xzjRBRww8d3lClA/kKwsKzuX93RS0Uv30hAD6q9wjqK/m6vR +5Y1SsvJYV0y+Yu5j9xUEsojMH7O3NlXWAYOb6oH+f/X7PX27IhtiCwfICMmVWh/h +KeXuFx6HSOcH3gZ6Tlk1llfDbE/ArpsZ6JmnLn73+64yqIoOZOc4JJUPrdsmbNwX +oxQSQhrpwjN8NpSkQaJbHGB3G+OWvP4fpqcweFHxlEq1HhefuR9E6jc3qwxVQfwj +bcq6N/4JAgMBAAECggEAb5SS4YwWc193S2v+QQ2KdVz6YEuINq/tRQw/TWGVACQT +PZzm3FaSXDsOsRAAjiSpWTgewgFyWVpBTGu4CZ73g8RZNvhGpWRwwW8KemCpg/8T +cEcnUYdKXdhuzAE9LETb7znwHM4Gj55DzCZopjfOLQ2Ne4XgAy2THaQcIjRKd6Bw +3mteJ2ityDj3iFN7cq9ntDzp+2BqLOi7AZmLntmUZxtkPCT6k5/dcKFYQW9Eb3bt +MON+BIYVzqhAijkP/cAWmbgZAP9EFng5PpE1lc/shl0W8eX4yvjNoMPRq3wphS4j +L16VncUeDep3vR0CECx7gnTfR0uCDEgKow50pzGQAQKBgQDaQWwK/o39zI3lCGzy +oSNJRNQJ/iZBkbbwpCCaka7VnBfd0ZH54VEWL3oMTkkWRSZtjsPAqT+ndwZitm0D +Kww9FUDMP7j/tMOwAUHYfjYFqFTn6ipkBuby9tbZtL7lgJO6Iu2Qk3afqADD0kcP +zRLxcYSLjrmp9NyUlNnpswR4CQKBgQDVxjwG/orCmiuyA1Bu4u1hdUD0w9CKnyjp +VTbkv8lxk5V3pYzms2Awb0X43W2OioYGBk5yw+9GCF//xCrfbGV7BLZnDTGShjkJ +8oTpLPGBsDSfaKVXE3Hko4LVLBMQIm0tDyuPD1Naia7ZknYn906skonEG8WgHUyp +c/BgkvzWAQKBgBdojuL6/FWtO8bFyZGYUMWJ+Uf9FzNPIpTatZh+aYcFj9W9pW9s +iBreCrQJLXOTBRUZC8u9G1Olw2yQ7k45rr1aazG83+WlCJv29o32s2qV7E1XYyaJ +SvniGZcN+K96w91h46Lu/fkPts1J309FinOU3kdtjmI5HfNdp6WWCrOpAoGBAMjc +TEaeIK8cwPWwG4E1A6pQy8mvu2Ckj4I+KSfh9FsdOpGDIdMas8SOqQZet7P5AFjk +0A0RgN8iu2DMZyQq62cdVG2bffqY1zs7fhrBueILOEaXwtMAWEFmSWYW1YqRbleq +K1luIvms6HdSIGcI/gk0XvG+zn/VR9ToNPHo6lwBAoGBAIrYGYPf+cjZ1V/tNqnL +IecEZb4Gkp1hVhOpNT4U+T2LROxrZtFxxsw2vuIRa5a5FtMbDq9Xyhkm0QppliBd +KQ38jTT0EaD2+vstTqL8vxupo25RQWV1XsmLL4pLbKnm2HnnwB3vEtsiokWKW0q0 +Tdb0MiLc+r/zvx8oXtgDjDUa +-----END PRIVATE KEY----- +''' + +def provide_private_key_to_test_client(testcase): + session = testcase.client.session + session['NOMCOM_PRIVATE_KEY_%s'%testcase.nc.year()] = key + session.save() + +def nomcom_kwargs_for_year(year=None, *args, **kwargs): + if not year: + year = random.randint(1980,2100) + if 'group__state_id' not in kwargs: + kwargs['group__state_id']='active' + if 'group__acronym' not in kwargs: + kwargs['group__acronym'] = 'nomcom%d'%year + if 'group__name' not in kwargs: + kwargs['group__name'] = 'TEST VERSION of IAB/IESG Nominating Committee %d/%d'%(year,year+1) + return kwargs + + +class NomComFactory(factory.DjangoModelFactory): + class Meta: + model = NomCom + + group = factory.SubFactory(GroupFactory,type_id='nomcom') + + public_key = factory.django.FileField(data=cert) + + @factory.post_generation + def populate_positions(self, create, extracted, **kwargs): + ''' + Create a set of nominees and positions unless NomcomFactory is called + with populate_positions=False + ''' + if extracted is None: + extracted = True + if create and extracted: + nominees = [NomineeFactory(nomcom=self) for i in range(4)] + positions = [PositionFactory(nomcom=self) for i in range(3)] + + def npc(position,nominee,state_id): + return NomineePosition.objects.create(position=position, + nominee=nominee, + state_id=state_id) + # This gives us positions with 0, 1 and 2 nominees, and + # one person who's been nominated for more than one position + npc(positions[0],nominees[0],'accepted') + npc(positions[1],nominees[0],'accepted') + npc(positions[1],nominees[1],'accepted') + npc(positions[0],nominees[2],'pending') + npc(positions[0],nominees[3],'declined') + + @factory.post_generation + def populate_personnel(self, create, extracted, **kwargs): + ''' + Create a default set of role holders, unless the factory is called + with populate_personnel=False + ''' + if extracted is None: + extracted = True + if create and extracted: + #roles= ['chair', 'advisor'] + ['member']*10 + roles = ['chair', 'advisor', 'member'] + for role in roles: + p = PersonFactory() + self.group.role_set.create(name_id=role,person=p,email=p.email_set.first()) + +class PositionFactory(factory.DjangoModelFactory): + class Meta: + model = Position + + name = factory.Faker('sentence',nb_words=10) + is_open = True + +class NomineeFactory(factory.DjangoModelFactory): + class Meta: + model = Nominee + + nomcom = factory.SubFactory(NomComFactory) + person = factory.SubFactory(PersonFactory) + email = factory.LazyAttribute(lambda n: n.person.email()) + +class FeedbackFactory(factory.DjangoModelFactory): + class Meta: + model = Feedback + + nomcom = factory.SubFactory(NomComFactory) + subject = factory.Faker('sentence') + comments = factory.Faker('paragraph') + type_id = 'comment' diff --git a/ietf/nomcom/forms.py b/ietf/nomcom/forms.py index ef3dbdd8a..3f96b24ac 100644 --- a/ietf/nomcom/forms.py +++ b/ietf/nomcom/forms.py @@ -2,26 +2,30 @@ from django.conf import settings from django import forms from django.contrib.formtools.preview import FormPreview, AUTO_ID from django.shortcuts import get_object_or_404, redirect -from django.template.loader import render_to_string from django.utils.decorators import method_decorator from django.shortcuts import render_to_response from django.template.context import RequestContext +from django.core.urlresolvers import reverse +from django.utils.html import mark_safe from ietf.dbtemplate.forms import DBTemplateForm from ietf.group.models import Group, Role from ietf.ietfauth.utils import role_required -from ietf.name.models import RoleName, FeedbackTypeName, NomineePositionStateName +from ietf.name.models import RoleName, FeedbackTypeName from ietf.nomcom.models import ( NomCom, Nomination, Nominee, NomineePosition, Position, Feedback, ReminderDates ) from ietf.nomcom.utils import (NOMINATION_RECEIPT_TEMPLATE, FEEDBACK_RECEIPT_TEMPLATE, get_user_email, validate_private_key, validate_public_key, - get_or_create_nominee, create_feedback_email) + make_nomineeposition, make_nomineeposition_for_newperson, + create_feedback_email) from ietf.person.models import Email -from ietf.person.fields import SearchableEmailField +from ietf.person.fields import SearchableEmailField, SearchablePersonField, SearchablePersonsField from ietf.utils.fields import MultiEmailField from ietf.utils.mail import send_mail from ietf.mailtrigger.utils import gather_address_lists +import debug # pyflakes:ignore + ROLODEX_URL = getattr(settings, 'ROLODEX_URL', None) @@ -40,9 +44,15 @@ class PositionNomineeField(forms.ChoiceField): positions = Position.objects.get_by_nomcom(self.nomcom).opened().order_by('name') results = [] for position in positions: - nominees = [('%s_%s' % (position.id, i.id), unicode(i)) for i in Nominee.objects.get_by_nomcom(self.nomcom).not_duplicated().filter(nominee_position=position).select_related("email", "email__person")] + accepted_nominees = [np.nominee for np in NomineePosition.objects.filter(position=position,state='accepted').exclude(nominee__duplicated__isnull=False)] + nominees = [('%s_%s' % (position.id, i.id), unicode(i)) for i in accepted_nominees] if nominees: - results.append((position.name, nominees)) + results.append((position.name+" (Accepted)", nominees)) + for position in positions: + other_nominees = [np.nominee for np in NomineePosition.objects.filter(position=position).exclude(state='accepted').exclude(nominee__duplicated__isnull=False)] + nominees = [('%s_%s' % (position.id, i.id), unicode(i)) for i in other_nominees] + if nominees: + results.append((position.name+" (Declined or Pending)", nominees)) kwargs['choices'] = results super(PositionNomineeField, self).__init__(*args, **kwargs) @@ -83,35 +93,10 @@ class MultiplePositionNomineeField(forms.MultipleChoiceField, PositionNomineeFie return result -class BaseNomcomForm(object): - def __unicode__(self): - return self.as_div() - - def as_div(self): - return render_to_string('nomcom/nomcomform.html', {'form': self}) - - def get_fieldsets(self): - if not self.fieldsets: - yield dict(name=None, fields=self) - else: - for fieldset, fields in self.fieldsets: - fieldset_dict = dict(name=fieldset, fields=[]) - for field_name in fields: - if field_name in self.fields: - fieldset_dict['fields'].append(self[field_name]) - if not fieldset_dict['fields']: - # if there is no fields in this fieldset, we continue to next fieldset - continue - yield fieldset_dict - - -class EditMembersForm(BaseNomcomForm, forms.Form): +class EditMembersForm(forms.Form): members = MultiEmailField(label="Members email", required=False, widget=forms.Textarea) - fieldsets = [('Members', ('members',))] - - class EditMembersFormPreview(FormPreview): form_template = 'nomcom/edit_members.html' preview_template = 'nomcom/edit_members_preview.html' @@ -208,10 +193,8 @@ class EditMembersFormPreview(FormPreview): return redirect('nomcom_edit_members', year=self.year) -class EditNomcomForm(BaseNomcomForm, forms.ModelForm): +class EditNomcomForm(forms.ModelForm): - fieldsets = [('Edit nomcom settings', ('public_key', 'initial_text', - 'send_questionnaire', 'reminder_interval'))] def __init__(self, *args, **kwargs): super(EditNomcomForm, self).__init__(*args, **kwargs) @@ -238,100 +221,42 @@ class EditNomcomForm(BaseNomcomForm, forms.ModelForm): raise forms.ValidationError('Invalid public key. Error was: %s' % error) -class MergeForm(BaseNomcomForm, forms.Form): +class MergeForm(forms.Form): - secondary_emails = MultiEmailField(label="Secondary email addresses", - help_text="Provide a comma separated list of email addresses. Nominations already received with any of these email address will be moved to show under the primary address.", widget=forms.Textarea) - primary_email = forms.EmailField(label="Primary email address", - widget=forms.TextInput(attrs={'size': '40'})) - - fieldsets = [('Emails', ('primary_email', 'secondary_emails'))] + primary_person = SearchablePersonField(help_text="Select the person you want the datatracker to keep") + duplicate_persons = SearchablePersonsField(help_text="Select all the duplicates that should be merged into the primary person record") def __init__(self, *args, **kwargs): self.nomcom = kwargs.pop('nomcom', None) super(MergeForm, self).__init__(*args, **kwargs) - def clean_primary_email(self): - email = self.cleaned_data['primary_email'] - nominees = Nominee.objects.get_by_nomcom(self.nomcom).not_duplicated().filter(email__address=email) - if not nominees: - msg = "No nominee with this email exists" - self._errors["primary_email"] = self.error_class([msg]) - - return email - - def clean_secondary_emails(self): - emails = self.cleaned_data['secondary_emails'] - for email in emails: - nominees = Nominee.objects.get_by_nomcom(self.nomcom).not_duplicated().filter(email__address=email) - if not nominees: - msg = "No nominee with email %s exists" % email - self._errors["primary_email"] = self.error_class([msg]) - break - - return emails - def clean(self): - primary_email = self.cleaned_data.get("primary_email") - secondary_emails = self.cleaned_data.get("secondary_emails") - if primary_email and secondary_emails: - if primary_email in secondary_emails: - msg = "Primary and secondary email address must be differents" - self._errors["primary_email"] = self.error_class([msg]) + primary_person = self.cleaned_data.get("primary_person") + duplicate_persons = self.cleaned_data.get("duplicate_persons") + if primary_person and duplicate_persons: + if primary_person in duplicate_persons: + msg = "The primary person must not also be listed as a duplicate person" + self._errors["primary_person"] = self.error_class([msg]) return self.cleaned_data def save(self): - primary_email = self.cleaned_data.get("primary_email") - secondary_emails = self.cleaned_data.get("secondary_emails") + primary_person = self.cleaned_data.get("primary_person") + duplicate_persons = self.cleaned_data.get("duplicate_persons") - primary_nominee = Nominee.objects.get_by_nomcom(self.nomcom).get(email__address=primary_email) - while primary_nominee.duplicated: - primary_nominee = primary_nominee.duplicated - secondary_nominees = Nominee.objects.get_by_nomcom(self.nomcom).filter(email__address__in=secondary_emails) - for nominee in secondary_nominees: - # move nominations - nominee.nomination_set.all().update(nominee=primary_nominee) - # move feedback - for fb in nominee.feedback_set.all(): - fb.nominees.remove(nominee) - fb.nominees.add(primary_nominee) - # move nomineepositions - for nominee_position in nominee.nomineeposition_set.all(): - primary_nominee_positions = NomineePosition.objects.filter(position=nominee_position.position, - nominee=primary_nominee) - primary_nominee_position = primary_nominee_positions and primary_nominee_positions[0] or None + subject = "Request to merge Person records" + from_email = settings.NOMCOM_FROM_EMAIL + (to_email, cc) = gather_address_lists('person_merge_requested') + context = {'primary_person':primary_person, 'duplicate_persons':duplicate_persons} + send_mail(None, to_email, from_email, subject, 'nomcom/merge_request.txt', context, cc=cc) - if primary_nominee_position: - # if already a nomineeposition object for a position and nominee, - # update the nomineepostion of primary nominee with the state - if nominee_position.state.slug == 'accepted' or primary_nominee_position.state.slug == 'accepted': - primary_nominee_position.state = NomineePositionStateName.objects.get(slug='accepted') - primary_nominee_position.save() - if nominee_position.state.slug == 'declined' and primary_nominee_position.state.slug == 'pending': - primary_nominee_position.state = NomineePositionStateName.objects.get(slug='declined') - primary_nominee_position.save() - else: - # It is not allowed two or more nomineeposition objects with same position and nominee - # move nominee_position object to primary nominee - nominee_position.nominee = primary_nominee - nominee_position.save() - - nominee.duplicated = primary_nominee - nominee.save() - - secondary_nominees.update(duplicated=primary_nominee) - - -class NominateForm(BaseNomcomForm, forms.ModelForm): - comments = forms.CharField(label="Candidate's qualifications for the position", +class NominateForm(forms.ModelForm): + searched_email = SearchableEmailField(only_users=False) + qualifications = forms.CharField(label="Candidate's qualifications for the position", widget=forms.Textarea()) - confirmation = forms.BooleanField(label='Email comments back to me as confirmation', + confirmation = forms.BooleanField(label='Email comments back to me as confirmation.', help_text="If you want to get a confirmation mail containing your feedback in cleartext, please check the 'email comments back to me as confirmation'.", required=False) - fieldsets = [('Candidate Nomination', ('share_nominator','position', 'candidate_name', - 'candidate_email', 'candidate_phone', 'comments', 'confirmation'))] - def __init__(self, *args, **kwargs): self.nomcom = kwargs.pop('nomcom', None) self.user = kwargs.pop('user', None) @@ -339,19 +264,16 @@ class NominateForm(BaseNomcomForm, forms.ModelForm): super(NominateForm, self).__init__(*args, **kwargs) - fieldset = ['share_nominator', - 'position', - 'candidate_name', - 'candidate_email', 'candidate_phone', - 'comments'] - + new_person_url_name = 'nomcom_%s_nominate_newperson' % ('public' if self.public else 'private' ) + self.fields['searched_email'].label = 'Candidate email' + self.fields['searched_email'].help_text = 'Search by name or email address. Click here if the search does not find the candidate you want to nominate.' % reverse(new_person_url_name,kwargs={'year':self.nomcom.year()}) self.fields['nominator_email'].label = 'Nominator email' if self.nomcom: self.fields['position'].queryset = Position.objects.get_by_nomcom(self.nomcom).opened() - self.fields['comments'].help_text = self.nomcom.initial_text + self.fields['qualifications'].help_text = self.nomcom.initial_text if not self.public: - fieldset = ['nominator_email'] + fieldset + self.fields.pop('confirmation') author = get_user_email(self.user) if author: self.fields['nominator_email'].initial = author.address @@ -363,22 +285,23 @@ class NominateForm(BaseNomcomForm, forms.ModelForm): has indicated they will allow NomCom to share their name as one of the people nominating this candidate.""" else: - fieldset.append('confirmation') + self.fields.pop('nominator_email') - self.fieldsets = [('Candidate Nomination', fieldset)] def save(self, commit=True): # Create nomination nomination = super(NominateForm, self).save(commit=False) nominator_email = self.cleaned_data.get('nominator_email', None) - candidate_email = self.cleaned_data['candidate_email'] - candidate_name = self.cleaned_data['candidate_name'] + searched_email = self.cleaned_data['searched_email'] position = self.cleaned_data['position'] - comments = self.cleaned_data['comments'] - confirmation = self.cleaned_data['confirmation'] + qualifications = self.cleaned_data['qualifications'] + confirmation = self.cleaned_data.get('confirmation', False) share_nominator = self.cleaned_data['share_nominator'] nomcom_template_path = '/nomcom/%s/' % self.nomcom.group.acronym + nomination.candidate_name = searched_email.person.plain_name() + nomination.candidate_email = searched_email.address + author = None if self.public: author = get_user_email(self.user) @@ -386,11 +309,11 @@ class NominateForm(BaseNomcomForm, forms.ModelForm): if nominator_email: emails = Email.objects.filter(address=nominator_email) author = emails and emails[0] or None - nominee = get_or_create_nominee(self.nomcom, candidate_name, candidate_email, position, author) + nominee = make_nomineeposition(self.nomcom, searched_email.person, position, author) # Complete nomination data feedback = Feedback.objects.create(nomcom=self.nomcom, - comments=comments, + comments=qualifications, type=FeedbackTypeName.objects.get(slug='nomina'), user=self.user) feedback.positions.add(position) @@ -416,7 +339,117 @@ class NominateForm(BaseNomcomForm, forms.ModelForm): from_email = settings.NOMCOM_FROM_EMAIL (to_email, cc) = gather_address_lists('nomination_receipt_requested',nominator=author.address) context = {'nominee': nominee.email.person.name, - 'comments': comments, + 'comments': qualifications, + 'position': position.name} + path = nomcom_template_path + NOMINATION_RECEIPT_TEMPLATE + send_mail(None, to_email, from_email, subject, path, context, cc=cc) + + return nomination + + class Meta: + model = Nomination + fields = ('share_nominator', 'position', 'nominator_email', 'searched_email', + 'candidate_phone', 'qualifications', 'confirmation') + +class NominateNewPersonForm(forms.ModelForm): + qualifications = forms.CharField(label="Candidate's qualifications for the position", + widget=forms.Textarea()) + confirmation = forms.BooleanField(label='Email comments back to me as confirmation.', + help_text="If you want to get a confirmation mail containing your feedback in cleartext, please check the 'email comments back to me as confirmation'.", + required=False) + + def __init__(self, *args, **kwargs): + self.nomcom = kwargs.pop('nomcom', None) + self.user = kwargs.pop('user', None) + self.public = kwargs.pop('public', None) + + super(NominateNewPersonForm, self).__init__(*args, **kwargs) + + self.fields['nominator_email'].label = 'Nominator email' + if self.nomcom: + self.fields['position'].queryset = Position.objects.get_by_nomcom(self.nomcom).opened() + self.fields['qualifications'].help_text = self.nomcom.initial_text + + if not self.public: + self.fields.pop('confirmation') + author = get_user_email(self.user) + if author: + self.fields['nominator_email'].initial = author.address + help_text = """(Nomcom Chair/Member: please fill this in. Use your own email address if the person making the + nomination wishes to be anonymous. The confirmation email will be sent to the address given here, + and the address will also be captured as part of the registered nomination.)""" + self.fields['nominator_email'].help_text = help_text + self.fields['share_nominator'].help_text = """(Nomcom Chair/Member: Check this box if the person providing this nomination + has indicated they will allow NomCom to share their name as one of the people + nominating this candidate.""" + else: + self.fields.pop('nominator_email') + + + def clean_candidate_email(self): + candidate_email = self.cleaned_data['candidate_email'] + if Email.objects.filter(address=candidate_email).exists(): + normal_url_name = 'nomcom_%s_nominate' % 'public' if self.public else 'private' + msg = '%s is already in the datatracker. \ + Use the normal nomination form to nominate the person \ + with this address.\ + ' % (candidate_email,reverse(normal_url_name,kwargs={'year':self.nomcom.year()})) + raise forms.ValidationError(mark_safe(msg)) + return candidate_email + + def save(self, commit=True): + # Create nomination + nomination = super(NominateNewPersonForm, self).save(commit=False) + nominator_email = self.cleaned_data.get('nominator_email', None) + candidate_email = self.cleaned_data['candidate_email'] + candidate_name = self.cleaned_data['candidate_name'] + position = self.cleaned_data['position'] + qualifications = self.cleaned_data['qualifications'] + confirmation = self.cleaned_data.get('confirmation', False) + share_nominator = self.cleaned_data['share_nominator'] + nomcom_template_path = '/nomcom/%s/' % self.nomcom.group.acronym + + + author = None + if self.public: + author = get_user_email(self.user) + else: + if nominator_email: + emails = Email.objects.filter(address=nominator_email) + author = emails and emails[0] or None + ## This is where it should change - validation of the email field should fail if the email exists + ## The function should become make_nominee_from_newperson) + nominee = make_nomineeposition_for_newperson(self.nomcom, candidate_name, candidate_email, position, author) + + # Complete nomination data + feedback = Feedback.objects.create(nomcom=self.nomcom, + comments=qualifications, + type=FeedbackTypeName.objects.get(slug='nomina'), + user=self.user) + feedback.positions.add(position) + feedback.nominees.add(nominee) + + if author: + nomination.nominator_email = author.address + feedback.author = author.address + feedback.save() + + nomination.nominee = nominee + nomination.comments = feedback + nomination.share_nominator = share_nominator + nomination.user = self.user + + if commit: + nomination.save() + + # send receipt email to nominator + if confirmation: + if author: + subject = 'Nomination receipt' + from_email = settings.NOMCOM_FROM_EMAIL + (to_email, cc) = gather_address_lists('nomination_receipt_requested',nominator=author.address) + context = {'nominee': nominee.email.person.name, + 'comments': qualifications, 'position': position.name} path = nomcom_template_path + NOMINATION_RECEIPT_TEMPLATE send_mail(None, to_email, from_email, subject, path, context, cc=cc) @@ -426,22 +459,15 @@ class NominateForm(BaseNomcomForm, forms.ModelForm): class Meta: model = Nomination fields = ('share_nominator', 'position', 'nominator_email', 'candidate_name', - 'candidate_email', 'candidate_phone') + 'candidate_email', 'candidate_phone', 'qualifications', 'confirmation') -class FeedbackForm(BaseNomcomForm, forms.ModelForm): - position_name = forms.CharField(label='Position', - widget=forms.TextInput(attrs={'size': '40'})) - nominee_name = forms.CharField(label='Nominee name', - widget=forms.TextInput(attrs={'size': '40'})) - nominee_email = forms.CharField(label='Nominee email', - widget=forms.TextInput(attrs={'size': '40'})) - nominator_email = forms.CharField(label='Commenter email') +class FeedbackForm(forms.ModelForm): + nominator_email = forms.CharField(label='Commenter email',required=False) - comments = forms.CharField(label='Comments on this nominee', + comments = forms.CharField(label='Comments', widget=forms.Textarea()) - confirmation = forms.BooleanField(label='Email comments back to me as confirmation', - help_text="If you want to get a confirmation mail containing your feedback in cleartext, please check the 'email comments back to me as confirmation'.", + confirmation = forms.BooleanField(label='Email comments back to me as confirmation (if selected, your comments will be emailed to you in cleartext when you press Save).', required=False) def __init__(self, *args, **kwargs): @@ -453,72 +479,44 @@ class FeedbackForm(BaseNomcomForm, forms.ModelForm): super(FeedbackForm, self).__init__(*args, **kwargs) - readonly_fields = ['position_name', - 'nominee_name', - 'nominee_email'] - - fieldset = ['position_name', - 'nominee_name', - 'nominee_email', - 'nominator_email', - 'comments'] + author = get_user_email(self.user) if self.public: - readonly_fields += ['nominator_email'] - fieldset.append('confirmation') + self.fields.pop('nominator_email') else: help_text = """(Nomcom Chair/Member: please fill this in. Use your own email address if the person making the comments wishes to be anonymous. The confirmation email will be sent to the address given here, and the address will also be captured as part of the registered nomination.)""" self.fields['nominator_email'].help_text = help_text - self.fields['nominator_email'].required = False + self.fields['confirmation'].label = 'Email these comments in cleartext to the provided commenter email address' + if author: + self.fields['nominator_email'].initial = author.address - author = get_user_email(self.user) - if author: - self.fields['nominator_email'].initial = author.address - - if self.position and self.nominee: - self.fields['position_name'].initial = self.position.name - self.fields['nominee_name'].initial = self.nominee.email.person.name - self.fields['nominee_email'].initial = self.nominee.email.address - else: - help_text = "Please pick a name on the nominees list" - self.fields['position_name'].initial = help_text - self.fields['nominee_name'].initial = help_text - self.fields['nominee_email'].initial = help_text - self.fields['comments'].initial = help_text - readonly_fields += ['comments'] - self.fields['confirmation'].widget.attrs['disabled'] = "disabled" - - for field in readonly_fields: - self.fields[field].widget.attrs['readonly'] = True - - self.fieldsets = [('Provide comments', fieldset)] def clean(self): if not NomineePosition.objects.accepted().filter(nominee=self.nominee, position=self.position): msg = "There isn't a accepted nomination for %s on the %s position" % (self.nominee, self.position) - self._errors["nominee_email"] = self.error_class([msg]) + self._errors["comments"] = self.error_class([msg]) return self.cleaned_data def save(self, commit=True): feedback = super(FeedbackForm, self).save(commit=False) confirmation = self.cleaned_data['confirmation'] comments = self.cleaned_data['comments'] - nominator_email = self.cleaned_data['nominator_email'] nomcom_template_path = '/nomcom/%s/' % self.nomcom.group.acronym author = None if self.public: author = get_user_email(self.user) else: + nominator_email = self.cleaned_data['nominator_email'] if nominator_email: emails = Email.objects.filter(address=nominator_email) author = emails and emails[0] or None if author: - feedback.author = author + feedback.author = author.address feedback.nomcom = self.nomcom feedback.user = self.user @@ -541,18 +539,16 @@ class FeedbackForm(BaseNomcomForm, forms.ModelForm): class Meta: model = Feedback - fields = ('nominee_name', - 'nominee_email', + fields = ( 'nominator_email', + 'comments', 'confirmation', - 'comments') + ) -class FeedbackEmailForm(BaseNomcomForm, forms.Form): +class FeedbackEmailForm(forms.Form): email_text = forms.CharField(label='Email text', widget=forms.Textarea()) - fieldsets = [('Feedback email', ('email_text',))] - def __init__(self, *args, **kwargs): self.nomcom = kwargs.pop('nomcom', None) super(FeedbackEmailForm, self).__init__(*args, **kwargs) @@ -560,12 +556,10 @@ class FeedbackEmailForm(BaseNomcomForm, forms.Form): def save(self, commit=True): create_feedback_email(self.nomcom, self.cleaned_data['email_text']) -class QuestionnaireForm(BaseNomcomForm, forms.ModelForm): +class QuestionnaireForm(forms.ModelForm): comments = forms.CharField(label='Questionnaire response from this candidate', widget=forms.Textarea()) - fieldsets = [('New questionnaire response', ('nominee', 'comments'))] - def __init__(self, *args, **kwargs): self.nomcom = kwargs.pop('nomcom', None) self.user = kwargs.pop('user', None) @@ -594,21 +588,14 @@ class QuestionnaireForm(BaseNomcomForm, forms.ModelForm): model = Feedback fields = ( 'comments', ) -class NomComTemplateForm(BaseNomcomForm, DBTemplateForm): +class NomComTemplateForm(DBTemplateForm): content = forms.CharField(label="Text", widget=forms.Textarea(attrs={'cols': '120', 'rows':'40', })) - fieldsets = [('Template content', ('content', )), ] - -class PositionForm(BaseNomcomForm, forms.ModelForm): - - fieldsets = [('Position', ('name', 'description', - 'is_open', 'incumbent'))] - - incumbent = SearchableEmailField(required=False) +class PositionForm(forms.ModelForm): class Meta: model = Position - fields = ('name', 'description', 'is_open', 'incumbent') + fields = ('name', 'is_open') def __init__(self, *args, **kwargs): self.nomcom = kwargs.pop('nomcom', None) @@ -619,12 +606,10 @@ class PositionForm(BaseNomcomForm, forms.ModelForm): super(PositionForm, self).save(*args, **kwargs) -class PrivateKeyForm(BaseNomcomForm, forms.Form): +class PrivateKeyForm(forms.Form): key = forms.CharField(label='Private key', widget=forms.Textarea(), required=False) - fieldsets = [('Private key', ('key',))] - def clean_key(self): key = self.cleaned_data.get('key', None) if not key: @@ -635,7 +620,7 @@ class PrivateKeyForm(BaseNomcomForm, forms.Form): raise forms.ValidationError('Invalid private key. Error was: %s' % error) -class PendingFeedbackForm(BaseNomcomForm, forms.ModelForm): +class PendingFeedbackForm(forms.ModelForm): type = forms.ModelChoiceField(queryset=FeedbackTypeName.objects.all().order_by('pk'), widget=forms.RadioSelect, empty_label='Unclassified', required=False) @@ -643,13 +628,6 @@ class PendingFeedbackForm(BaseNomcomForm, forms.ModelForm): model = Feedback fields = ('type', ) - def __init__(self, *args, **kwargs): - super(PendingFeedbackForm, self).__init__(*args, **kwargs) - try: - self.default_type = FeedbackTypeName.objects.get(slug=settings.DEFAULT_FEEDBACK_TYPE) - except FeedbackTypeName.DoesNotExist: - self.default_type = None - def set_nomcom(self, nomcom, user): self.nomcom = nomcom self.user = user @@ -665,17 +643,6 @@ class PendingFeedbackForm(BaseNomcomForm, forms.ModelForm): feedback.save() return feedback - def move_to_default(self): - if not self.default_type or self.cleaned_data.get('type', None): - return None - feedback = super(PendingFeedbackForm, self).save(commit=False) - feedback.nomcom = self.nomcom - feedback.user = self.user - feedback.type = self.default_type - feedback.save() - return feedback - - class ReminderDatesForm(forms.ModelForm): class Meta: @@ -711,17 +678,38 @@ class MutableFeedbackForm(forms.ModelForm): if self.feedback_type.slug != 'nomina': self.fields['nominee'] = MultiplePositionNomineeField(nomcom=self.nomcom, required=True, - widget=forms.SelectMultiple, + widget=forms.SelectMultiple(attrs={'class':'nominee_multi_select','size':'12'}), help_text='Hold down "Control", or "Command" on a Mac, to select more than one.') else: self.fields['position'] = forms.ModelChoiceField(queryset=Position.objects.get_by_nomcom(self.nomcom).opened(), label="Position") - self.fields['candidate_name'] = forms.CharField(label="Candidate name") - self.fields['candidate_email'] = forms.EmailField(label="Candidate email") + self.fields['searched_email'] = SearchableEmailField(only_users=False,help_text="Try to find the candidate you are classifying with this field first. Only use the name and email fields below if this search does not find the candidate.",label="Candidate",required=False) + self.fields['candidate_name'] = forms.CharField(label="Candidate name",help_text="Only fill in this name field if the search doesn't find the person you are classifying",required=False) + self.fields['candidate_email'] = forms.EmailField(label="Candidate email",help_text="Only fill in this email field if the search doesn't find the person you are classifying",required=False) self.fields['candidate_phone'] = forms.CharField(label="Candidate phone", required=False) + def clean(self): + cleaned_data = super(MutableFeedbackForm,self).clean() + if self.feedback_type.slug == 'nomina': + searched_email = self.cleaned_data.get('searched_email') + candidate_name = self.cleaned_data.get('candidate_name') + if candidate_name: + candidate_name = candidate_name.strip() + candidate_email = self.cleaned_data.get('candidate_email') + if candidate_email: + candidate_email = candidate_email.strip() + + if not any([ searched_email and not candidate_name and not candidate_email, + not searched_email and candidate_name and candidate_email, + ]): + raise forms.ValidationError("You must identify either an existing person (by searching with the candidate field) and leave the name and email fields blank, or leave the search field blank and provide both a name and email address.") + if candidate_email and Email.objects.filter(address=candidate_email).exists(): + raise forms.ValidationError("%s already exists in the datatracker. Please search within the candidate field to find it and leave both the name and email fields blank." % candidate_email) + return cleaned_data + def save(self, commit=True): feedback = super(MutableFeedbackForm, self).save(commit=False) if self.instance.type.slug == 'nomina': + searched_email = self.cleaned_data['searched_email'] candidate_email = self.cleaned_data['candidate_email'] candidate_name = self.cleaned_data['candidate_name'] candidate_phone = self.cleaned_data['candidate_phone'] @@ -733,7 +721,10 @@ class MutableFeedbackForm(forms.ModelForm): emails = Email.objects.filter(address=nominator_email) author = emails and emails[0] or None - nominee = get_or_create_nominee(self.nomcom, candidate_name, candidate_email, position, author) + if searched_email: + nominee = make_nomineeposition(self.nomcom, searched_email.person, position, author) + else: + nominee = make_nomineeposition_for_newperson(self.nomcom, candidate_name, candidate_email, position, author) feedback.nominees.add(nominee) feedback.positions.add(position) Nomination.objects.create( @@ -768,41 +759,25 @@ FullFeedbackFormSet = forms.modelformset_factory( class EditNomineeForm(forms.ModelForm): - nominee_email = forms.EmailField(label="Nominee email", - widget=forms.TextInput(attrs={'size': '40'})) + nominee_email = forms.ModelChoiceField(queryset=Email.objects.none(),empty_label=None) def __init__(self, *args, **kwargs): super(EditNomineeForm, self).__init__(*args, **kwargs) - self.fields['nominee_email'].initial = self.instance.email.address + self.fields['nominee_email'].queryset = Email.objects.filter(person=self.instance.person,active=True) + self.fields['nominee_email'].initial = self.instance.email + self.fields['nominee_email'].help_text = "If the address you are looking for does not appear in this list, ask the nominee (or the secretariat) to add the address to thier datatracker account and ensure it is marked as active." def save(self, commit=True): nominee = super(EditNomineeForm, self).save(commit=False) nominee_email = self.cleaned_data.get("nominee_email") - if nominee_email != nominee.email.address: - # create a new nominee with the new email - new_email, created_email = Email.objects.get_or_create(address=nominee_email) - new_email.person = nominee.email.person - new_email.save() - - # Chage emails between nominees - old_email = nominee.email - nominee.email = new_email - nominee.save() - new_nominee = Nominee.objects.create(email=old_email, nomcom=nominee.nomcom) - - # new nominees point to old nominee - new_nominee.duplicated = nominee - new_nominee.save() - + nominee.email = nominee_email + nominee.save() return nominee class Meta: model = Nominee fields = ('nominee_email',) - def clean_nominee_email(self): - nominee_email = self.cleaned_data['nominee_email'] - nominees = Nominee.objects.exclude(email__address=self.instance.email.address).filter(email__address=nominee_email) - if nominees: - raise forms.ValidationError('This emails already does exists in another nominee, please go to merge form') - return nominee_email +class NominationResponseCommentForm(forms.Form): + comments = forms.CharField(widget=forms.Textarea,required=False,help_text="Any comments provided will be encrytped and will only be visible to the NomCom.") + diff --git a/ietf/nomcom/migrations/0005_remove_position_incumbent.py b/ietf/nomcom/migrations/0005_remove_position_incumbent.py new file mode 100644 index 000000000..1b59c0855 --- /dev/null +++ b/ietf/nomcom/migrations/0005_remove_position_incumbent.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('nomcom', '0004_auto_20151027_0829'), + ] + + operations = [ + migrations.RemoveField( + model_name='position', + name='incumbent', + ), + ] diff --git a/ietf/nomcom/migrations/0006_improve_default_questionnaire_templates.py b/ietf/nomcom/migrations/0006_improve_default_questionnaire_templates.py new file mode 100644 index 000000000..60b1622dd --- /dev/null +++ b/ietf/nomcom/migrations/0006_improve_default_questionnaire_templates.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +def set_new_template_content(apps, schema_editor): + DBTemplate = apps.get_model('dbtemplate','DBTemplate') + + h = DBTemplate.objects.get(path='/nomcom/defaults/position/header_questionnaire.txt') + h.content = """Hi $nominee, this is the questionnaire for the position $position. +Please follow the directions in the questionnaire closely - you may see +that some changes have been made from previous years, so please take note. + +We look forward to reading your questionnaire response! If you have any +administrative questions, please send mail to nomcom-chair@ietf.org. + +You may have received this questionnaire before accepting the nomination. A +separate message, sent at the time of nomination, provides instructions for +indicating whether you accept or decline. If you have not completed those +steps, please do so as soon as possible, or contact the nomcom chair. + +Thank you! + + +""" + h.save() + + h = DBTemplate.objects.get(path='/nomcom/defaults/position/questionnaire.txt') + h.content = """NomCom Chair: Replace this content with the appropriate questionnaire for the position $position. +""" + h.save() + +def revert_to_old_template_content(apps, schema_editor): + DBTemplate = apps.get_model('dbtemplate','DBTemplate') + + h = DBTemplate.objects.get(path='/nomcom/defaults/position/header_questionnaire.txt') + h.content = """Hi $nominee, this is the questionnaire for the position $position. +Please follow the directions in the questionnaire closely - you may see +that some changes have been made from previous years, so please take note. + +We look forward to reading your questionnaire response! If you have any +administrative questions, please send mail to nomcom-chair@ietf.org. + +Thank you! + + +""" + h.save() + + h = DBTemplate.objects.get(path='/nomcom/defaults/position/questionnaire.txt') + h.content = """Enter here the questionnaire for the position $position: + +Questionnaire + +""" + h.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('nomcom', '0005_remove_position_incumbent'), + ] + + operations = [ + migrations.RunPython(set_new_template_content,revert_to_old_template_content) + ] diff --git a/ietf/nomcom/migrations/0007_feedbacklastseen.py b/ietf/nomcom/migrations/0007_feedbacklastseen.py new file mode 100644 index 000000000..b387b0045 --- /dev/null +++ b/ietf/nomcom/migrations/0007_feedbacklastseen.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import datetime + +from django.db import models, migrations + +def create_lastseen(apps, schema_editor): + NomCom = apps.get_model('nomcom','NomCom') + FeedbackLastSeen = apps.get_model('nomcom','FeedbackLastSeen') + now = datetime.datetime.now() + for nc in NomCom.objects.all(): + reviewers = [r.person for r in nc.group.role_set.all()] + nominees = nc.nominee_set.all() + for r in reviewers: + for n in nominees: + FeedbackLastSeen.objects.create(reviewer=r,nominee=n,time=now) + +def remove_lastseen(apps, schema_editor): + FeedbackLastSeen = apps.get_model('nomcom','FeedbackLastSeen') + FeedbackLastSeen.objects.delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('person', '0004_auto_20150308_0440'), + ('group', '0006_auto_20150718_0509'), + ('nomcom', '0006_improve_default_questionnaire_templates'), + ] + + operations = [ + + migrations.CreateModel( + name='FeedbackLastSeen', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('time', models.DateTimeField(auto_now=True)), + ('nominee', models.ForeignKey(to='nomcom.Nominee')), + ('reviewer', models.ForeignKey(to='person.Person')), + ], + options={ + }, + bases=(models.Model,), + ), + + migrations.RunPython(create_lastseen,remove_lastseen) + + ] diff --git a/ietf/nomcom/migrations/0008_auto_20151209_1423.py b/ietf/nomcom/migrations/0008_auto_20151209_1423.py new file mode 100644 index 000000000..8d8861a6b --- /dev/null +++ b/ietf/nomcom/migrations/0008_auto_20151209_1423.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('nomcom', '0007_feedbacklastseen'), + ] + + operations = [ + migrations.RemoveField( + model_name='position', + name='description', + ), + migrations.AlterField( + model_name='position', + name='name', + field=models.CharField(help_text=b'This short description will appear on the Nomination and Feedback pages. Be as descriptive as necessary. Past examples: "Transport AD", "IAB Member"', max_length=255, verbose_name=b'Name'), + preserve_default=True, + ), + ] diff --git a/ietf/nomcom/migrations/0009_remove_requirements_dbtemplate_type_from_path.py b/ietf/nomcom/migrations/0009_remove_requirements_dbtemplate_type_from_path.py new file mode 100644 index 000000000..00830a534 --- /dev/null +++ b/ietf/nomcom/migrations/0009_remove_requirements_dbtemplate_type_from_path.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +def remove_extension(apps, schema_editor): + DBTemplate = apps.get_model('dbtemplate','DBTemplate') + for template in DBTemplate.objects.filter(path__endswith="requirements.txt"): + template.path = template.path[:-4] + template.save() + +def restore_extension(apps, schema_editor): + DBTemplate = apps.get_model('dbtemplate','DBTemplate') + for template in DBTemplate.objects.filter(path__endswith="requirements"): + template.path = template.path+".txt" + template.save() + +def default_rst(apps, schema_editor): + DBTemplate = apps.get_model('dbtemplate','DBTemplate') + default_req = DBTemplate.objects.get(path__startswith='/nomcom/defaults/position/requirements') + default_req.type_id = 'rst' + default_req.save() + +def default_plain(apps, schema_editor): + DBTemplate = apps.get_model('dbtemplate','DBTemplate') + default_req = DBTemplate.objects.get(path__startswith='/nomcom/defaults/position/requirements') + default_req.type_id = 'plain' + default_req.save() + +def rst_2015(apps, schema_editor): + DBTemplate = apps.get_model('dbtemplate','DBTemplate') + DBTemplate.objects.filter(path__startswith='/nomcom/nomcom2015/').filter(path__contains='position/requirements').exclude(path__contains='/27/').update(type_id='rst') + +def plain_2015(apps, schema_editor): + DBTemplate = apps.get_model('dbtemplate','DBTemplate') + DBTemplate.objects.filter(path__startswith='/nomcom/nomcom2015/').filter(path__contains='position/requirements').update(type_id='plain') + +class Migration(migrations.Migration): + + dependencies = [ + ('nomcom', '0008_auto_20151209_1423'), + ('dbtemplate', '0002_auto_20141222_1749'), + ] + + operations = [ + migrations.RunPython(remove_extension,restore_extension), + migrations.RunPython(default_rst,default_plain), + migrations.RunPython(rst_2015,plain_2015), + ] diff --git a/ietf/nomcom/migrations/0010_nominee_person.py b/ietf/nomcom/migrations/0010_nominee_person.py new file mode 100644 index 000000000..3ff549479 --- /dev/null +++ b/ietf/nomcom/migrations/0010_nominee_person.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + +def populate_person(apps, schema_editor): + Nominee = apps.get_model('nomcom','Nominee') + for n in Nominee.objects.all(): + n.person = n.email.person + n.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('person', '0004_auto_20150308_0440'), + ('nomcom', '0009_remove_requirements_dbtemplate_type_from_path'), + ] + + operations = [ + migrations.AddField( + model_name='nominee', + name='person', + field=models.ForeignKey(blank=True, to='person.Person', null=True), + preserve_default=True, + ), + migrations.RunPython(populate_person,None) + ] diff --git a/ietf/nomcom/models.py b/ietf/nomcom/models.py index 6b466dd98..ea6f24983 100644 --- a/ietf/nomcom/models.py +++ b/ietf/nomcom/models.py @@ -7,9 +7,10 @@ from django.conf import settings from django.core.files.storage import FileSystemStorage from django.contrib.auth.models import User from django.template.loader import render_to_string +from django.template.defaultfilters import linebreaks from ietf.nomcom.fields import EncryptedTextField -from ietf.person.models import Email +from ietf.person.models import Person,Email from ietf.group.models import Group from ietf.name.models import NomineePositionStateName, FeedbackTypeName from ietf.dbtemplate.models import DBTemplate @@ -66,6 +67,14 @@ class NomCom(models.Model): if created: initialize_templates_for_group(self) + def year(self): + year = getattr(self,'_cached_year',None) + if year is None: + if self.group and self.group.acronym.startswith('nomcom'): + year = int(self.group.acronym[6:]) + self._cached_year = year + return year + def delete_nomcom(sender, **kwargs): nomcom = kwargs.get('instance', None) @@ -102,6 +111,7 @@ class Nomination(models.Model): class Nominee(models.Model): email = models.ForeignKey(Email) + person = models.ForeignKey(Person, blank=True, null=True) nominee_position = models.ManyToManyField('Position', through='NomineePosition') duplicated = models.ForeignKey('Nominee', blank=True, null=True) nomcom = models.ForeignKey('NomCom') @@ -118,6 +128,12 @@ class Nominee(models.Model): else: return self.email.address + def name(self): + if self.email.person and self.email.person.name: + return u'%s' % (self.email.person.plain_name(),) + else: + return self.email.address + class NomineePosition(models.Model): @@ -150,12 +166,10 @@ class NomineePosition(models.Model): class Position(models.Model): nomcom = models.ForeignKey('NomCom') - name = models.CharField(verbose_name='Name', max_length=255) - description = models.TextField(verbose_name='Description') + name = models.CharField(verbose_name='Name', max_length=255, help_text='This short description will appear on the Nomination and Feedback pages. Be as descriptive as necessary. Past examples: "Transport AD", "IAB Member"') requirement = models.ForeignKey(DBTemplate, related_name='requirement', null=True, editable=False) questionnaire = models.ForeignKey(DBTemplate, related_name='questionnaire', null=True, editable=False) is_open = models.BooleanField(verbose_name='Is open', default=False) - incumbent = models.ForeignKey(Email, null=True, blank=True) objects = PositionManager() @@ -189,7 +203,10 @@ class Position(models.Model): return render_to_string(self.questionnaire.path, {'position': self}) def get_requirement(self): - return render_to_string(self.requirement.path, {'position': self}) + rendered = render_to_string(self.requirement.path, {'position': self}) + if self.requirement.type_id=='plain': + rendered = linebreaks(rendered) + return rendered class Feedback(models.Model): @@ -211,4 +228,7 @@ class Feedback(models.Model): class Meta: ordering = ['time'] - +class FeedbackLastSeen(models.Model): + reviewer = models.ForeignKey(Person) + nominee = models.ForeignKey(Nominee) + time = models.DateTimeField(auto_now=True) diff --git a/ietf/nomcom/resources.py b/ietf/nomcom/resources.py index 61d10a2f0..0ab7115a5 100644 --- a/ietf/nomcom/resources.py +++ b/ietf/nomcom/resources.py @@ -25,13 +25,11 @@ class NomComResource(ModelResource): } api.nomcom.register(NomComResource()) -from ietf.person.resources import EmailResource from ietf.dbtemplate.resources import DBTemplateResource class PositionResource(ModelResource): nomcom = ToOneField(NomComResource, 'nomcom') requirement = ToOneField(DBTemplateResource, 'requirement', null=True) questionnaire = ToOneField(DBTemplateResource, 'questionnaire', null=True) - incumbent = ToOneField(EmailResource, 'incumbent', null=True) class Meta: queryset = Position.objects.all() serializer = api.Serializer() @@ -39,12 +37,10 @@ class PositionResource(ModelResource): filtering = { "id": ALL, "name": ALL, - "description": ALL, "is_open": ALL, "nomcom": ALL_WITH_RELATIONS, "requirement": ALL_WITH_RELATIONS, "questionnaire": ALL_WITH_RELATIONS, - "incumbent": ALL_WITH_RELATIONS, } api.nomcom.register(PositionResource()) @@ -148,3 +144,17 @@ class NominationResource(ModelResource): } api.nomcom.register(NominationResource()) +from ietf.person.resources import PersonResource +class FeedbackLastSeenResource(ModelResource): + reviewer = ToOneField(PersonResource, 'reviewer') + nominee = ToOneField(NomineeResource, 'nominee') + class Meta: + queryset = FeedbackLastSeen.objects.all() + serializer = api.Serializer() + filtering = { + "id": ALL, + "time": ALL, + "reviewer": ALL_WITH_RELATIONS, + "nominee": ALL_WITH_RELATIONS, + } +api.nomcom.register(FeedbackLastSeenResource()) diff --git a/ietf/nomcom/templatetags/nomcom_tags.py b/ietf/nomcom/templatetags/nomcom_tags.py index 85a023123..0af233808 100644 --- a/ietf/nomcom/templatetags/nomcom_tags.py +++ b/ietf/nomcom/templatetags/nomcom_tags.py @@ -7,43 +7,32 @@ from django.template.defaultfilters import linebreaksbr, force_escape from ietf.utils.pipe import pipe from ietf.utils.log import log -from ietf.ietfauth.utils import has_role from ietf.doc.templatetags.ietf_filters import wrap_text from ietf.person.models import Person -from ietf.nomcom.models import Feedback -from ietf.nomcom.utils import get_nomcom_by_year, get_user_email, retrieve_nomcom_private_key +from ietf.nomcom.utils import get_nomcom_by_year, retrieve_nomcom_private_key + +import debug # pyflakes:ignore register = template.Library() @register.filter -def is_chair(user, year): +def is_chair_or_advisor(user, year): if not user or not year: return False nomcom = get_nomcom_by_year(year=year) - if has_role(user, "Secretariat"): - return True - return nomcom.group.has_role(user, "chair") + return nomcom.group.has_role(user, ["chair","advisor"]) @register.filter def has_publickey(nomcom): return nomcom and nomcom.public_key and True or False - -@register.simple_tag -def add_num_nominations(user, position, nominee): - author = get_user_email(user) - - count = Feedback.objects.filter(positions__in=[position], - nominees__in=[nominee], - author=author, - type='comment').count() - - return '%s ' % (count, nominee.email.address, position, count) - +@register.filter +def lookup(container,key): + return container and container.get(key,None) @register.filter def formatted_email(address): diff --git a/ietf/nomcom/test_data.py b/ietf/nomcom/test_data.py index 11f1956f3..bee016d35 100644 --- a/ietf/nomcom/test_data.py +++ b/ietf/nomcom/test_data.py @@ -21,19 +21,19 @@ SECRETARIAT_USER = 'secretary' EMAIL_DOMAIN = '@example.com' NOMCOM_YEAR = "2013" -POSITIONS = { - "GEN": "IETF Chair/Gen AD", - "APP": "APP Area Director", - "INT": "INT Area Director", - "OAM": "OPS Area Director", - "OPS": "OPS Area Director", - "RAI": "RAI Area Director", - "RTG": "RTG Area Director", - "SEC": "SEC Area Director", - "TSV": "TSV Area Director", - "IAB": "IAB Member", - "IAOC": "IAOC Member", - } +POSITIONS = [ + "GEN", + "APP", + "INT", + "OAM", + "OPS", + "RAI", + "RTG", + "SEC", + "TSV", + "IAB", + "IAOC" + ] def generate_cert(): @@ -127,12 +127,10 @@ def nomcom_test_data(): nominee, _ = Nominee.objects.get_or_create(email=email, nomcom=nomcom) # positions - for name, description in POSITIONS.iteritems(): + for name in POSITIONS: position, created = Position.objects.get_or_create(nomcom=nomcom, name=name, - description=description, - is_open=True, - incumbent=email) + is_open=True) ChangeStateGroupEvent.objects.get_or_create(group=group, type="changed_state", diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index a5babc108..5ccbc4c34 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -4,8 +4,10 @@ import datetime import os import shutil from pyquery import PyQuery +import StringIO from django.db import IntegrityError +from django.db.models import Max from django.conf import settings from django.core.urlresolvers import reverse from django.core.files import File @@ -20,16 +22,25 @@ from ietf.person.models import Email, Person from ietf.group.models import Group from ietf.message.models import Message +from ietf.person.utils import merge_persons + from ietf.nomcom.test_data import nomcom_test_data, generate_cert, check_comments, \ COMMUNITY_USER, CHAIR_USER, \ MEMBER_USER, SECRETARIAT_USER, EMAIL_DOMAIN, NOMCOM_YEAR from ietf.nomcom.models import NomineePosition, Position, Nominee, \ NomineePositionStateName, Feedback, FeedbackTypeName, \ - Nomination + Nomination, FeedbackLastSeen from ietf.nomcom.forms import EditMembersForm, EditMembersFormPreview -from ietf.nomcom.utils import get_nomcom_by_year, get_or_create_nominee +from ietf.nomcom.utils import get_nomcom_by_year, make_nomineeposition, get_hash_nominee_position from ietf.nomcom.management.commands.send_reminders import Command, is_time_to_send +from ietf.nomcom.factories import NomComFactory, FeedbackFactory, \ + nomcom_kwargs_for_year, provide_private_key_to_test_client, \ + key +from ietf.person.factories import PersonFactory, EmailFactory, UserFactory +from ietf.dbtemplate.factories import DBTemplateFactory +from ietf.dbtemplate.models import DBTemplate + client_test_cert_files = None def get_cert_files(): @@ -38,6 +49,14 @@ def get_cert_files(): client_test_cert_files = generate_cert() return client_test_cert_files +def build_test_public_keys_dir(obj): + obj.nomcom_public_keys_dir = os.path.abspath("tmp-nomcom-public-keys-dir") + if not os.path.exists(obj.nomcom_public_keys_dir): + os.mkdir(obj.nomcom_public_keys_dir) + settings.NOMCOM_PUBLIC_KEYS_DIR = obj.nomcom_public_keys_dir + +def clean_test_public_keys_dir(obj): + shutil.rmtree(obj.nomcom_public_keys_dir) class NomcomViewsTest(TestCase): """Tests to create a new nomcom""" @@ -48,11 +67,7 @@ class NomcomViewsTest(TestCase): return response def setUp(self): - self.nomcom_public_keys_dir = os.path.abspath("tmp-nomcom-public-keys-dir") - if not os.path.exists(self.nomcom_public_keys_dir): - os.mkdir(self.nomcom_public_keys_dir) - settings.NOMCOM_PUBLIC_KEYS_DIR = self.nomcom_public_keys_dir - + build_test_public_keys_dir(self) nomcom_test_data() self.cert_file, self.privatekey_file = get_cert_files() self.year = NOMCOM_YEAR @@ -63,6 +78,7 @@ class NomcomViewsTest(TestCase): self.edit_members_url = reverse('nomcom_edit_members', kwargs={'year': self.year}) self.edit_nomcom_url = reverse('nomcom_edit_nomcom', kwargs={'year': self.year}) self.private_nominate_url = reverse('nomcom_private_nominate', kwargs={'year': self.year}) + self.private_nominate_newperson_url = reverse('nomcom_private_nominate_newperson', kwargs={'year': self.year}) self.add_questionnaire_url = reverse('nomcom_private_questionnaire', kwargs={'year': self.year}) self.private_feedback_url = reverse('nomcom_private_feedback', kwargs={'year': self.year}) self.positions_url = reverse("nomcom_list_positions", kwargs={'year': self.year}) @@ -74,9 +90,10 @@ class NomcomViewsTest(TestCase): self.questionnaires_url = reverse('nomcom_questionnaires', kwargs={'year': self.year}) self.public_feedback_url = reverse('nomcom_public_feedback', kwargs={'year': self.year}) self.public_nominate_url = reverse('nomcom_public_nominate', kwargs={'year': self.year}) + self.public_nominate_newperson_url = reverse('nomcom_public_nominate_newperson', kwargs={'year': self.year}) def tearDown(self): - shutil.rmtree(self.nomcom_public_keys_dir) + clean_test_public_keys_dir(self) def access_member_url(self, url): login_testing_unauthorized(self, COMMUNITY_USER, url) @@ -126,7 +143,7 @@ class NomcomViewsTest(TestCase): r = self.client.post(self.private_index_url, test_data) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) - self.assertNotEqual(q('p.alert.alert-success'), []) + self.assertTrue(q('.alert-success')) self.assertEqual(NomineePosition.objects.filter(state='accepted').count (), 1) self.client.logout() @@ -138,7 +155,7 @@ class NomcomViewsTest(TestCase): r = self.client.post(self.private_index_url, test_data) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) - self.assertNotEqual(q('p.alert.alert-success'), []) + self.assertTrue(q('.alert-success')) self.assertEqual(NomineePosition.objects.filter(state='declined').count (), 1) self.client.logout() @@ -150,210 +167,11 @@ class NomcomViewsTest(TestCase): r = self.client.post(self.private_index_url, test_data) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) - self.assertNotEqual(q('p.alert.alert-success'), []) + self.assertTrue(q('.alert-success')) self.assertEqual(NomineePosition.objects.filter(state='pending').count (), 1) self.client.logout() - def test_private_merge_view(self): - """Verify private merge view""" - - nominees = [u'nominee0@example.com', - u'nominee1@example.com', - u'nominee2@example.com', - u'nominee3@example.com'] - - # do nominations - login_testing_unauthorized(self, COMMUNITY_USER, self.public_nominate_url) - self.nominate_view(public=True, - nominee_email=nominees[0], - position='IAOC') - self.nominate_view(public=True, - nominee_email=nominees[0], - position='IAOC') - self.nominate_view(public=True, - nominee_email=nominees[0], - position='IAB') - self.nominate_view(public=True, - nominee_email=nominees[0], - position='TSV') - self.nominate_view(public=True, - nominee_email=nominees[1], - position='IAOC') - self.nominate_view(public=True, - nominee_email=nominees[1], - position='IAOC') - self.nominate_view(public=True, - nominee_email=nominees[2], - position='IAB') - self.nominate_view(public=True, - nominee_email=nominees[2], - position='IAB') - self.nominate_view(public=True, - nominee_email=nominees[3], - position='TSV') - self.nominate_view(public=True, - nominee_email=nominees[3], - position='TSV') - # Check nominee positions - self.assertEqual(NomineePosition.objects.count(), 6) - self.assertEqual(Feedback.objects.nominations().count(), 10) - - # Accept and declined nominations - nominee_position = NomineePosition.objects.get(position__name='IAOC', - nominee__email__address=nominees[0]) - nominee_position.state = NomineePositionStateName.objects.get(slug='accepted') - nominee_position.save() - - nominee_position = NomineePosition.objects.get(position__name='IAOC', - nominee__email__address=nominees[1]) - nominee_position.state = NomineePositionStateName.objects.get(slug='declined') - nominee_position.save() - - nominee_position = NomineePosition.objects.get(position__name='IAB', - nominee__email__address=nominees[2]) - nominee_position.state = NomineePositionStateName.objects.get(slug='declined') - nominee_position.save() - - nominee_position = NomineePosition.objects.get(position__name='TSV', - nominee__email__address=nominees[3]) - nominee_position.state = NomineePositionStateName.objects.get(slug='accepted') - nominee_position.save() - - self.client.logout() - - # fill questionnaires (internally the function does new nominations) - self.access_chair_url(self.add_questionnaire_url) - - self.add_questionnaire(public=False, - nominee_email=nominees[0], - position='IAOC') - self.add_questionnaire(public=False, - nominee_email=nominees[1], - position='IAOC') - self.add_questionnaire(public=False, - nominee_email=nominees[2], - position='IAB') - self.add_questionnaire(public=False, - nominee_email=nominees[3], - position='TSV') - self.assertEqual(Feedback.objects.questionnaires().count(), 4) - - self.client.logout() - - ## Add feedbacks (internally the function does new nominations) - self.access_member_url(self.private_feedback_url) - self.feedback_view(public=False, - nominee_email=nominees[0], - position='IAOC') - self.feedback_view(public=False, - nominee_email=nominees[1], - position='IAOC') - self.feedback_view(public=False, - nominee_email=nominees[2], - position='IAB') - self.feedback_view(public=False, - nominee_email=nominees[3], - position='TSV') - - self.assertEqual(Feedback.objects.comments().count(), 4) - self.assertEqual(Feedback.objects.nominations().count(), 18) - self.assertEqual(Feedback.objects.nominations().filter(nominees__email__address=nominees[0]).count(), 6) - self.assertEqual(Feedback.objects.nominations().filter(nominees__email__address=nominees[1]).count(), 4) - self.assertEqual(Feedback.objects.nominations().filter(nominees__email__address=nominees[2]).count(), 4) - self.assertEqual(Feedback.objects.nominations().filter(nominees__email__address=nominees[3]).count(), 4) - for nominee in nominees: - self.assertEqual(Feedback.objects.comments().filter(nominees__email__address=nominee).count(), - 1) - self.assertEqual(Feedback.objects.questionnaires().filter(nominees__email__address=nominee).count(), - 1) - - self.client.logout() - - ## merge nominations - self.access_chair_url(self.private_merge_url) - - test_data = {"secondary_emails": "%s, %s" % (nominees[0], nominees[1]), - "primary_email": nominees[0]} - response = self.client.post(self.private_merge_url, test_data) - self.assertEqual(response.status_code, 200) - q = PyQuery(response.content) - self.assertTrue(q("form .has-error")) - - test_data = {"primary_email": nominees[0], - "secondary_emails": ""} - response = self.client.post(self.private_merge_url, test_data) - self.assertEqual(response.status_code, 200) - q = PyQuery(response.content) - self.assertTrue(q("form .has-error")) - - test_data = {"primary_email": "", - "secondary_emails": nominees[0]} - response = self.client.post(self.private_merge_url, test_data) - self.assertEqual(response.status_code, 200) - q = PyQuery(response.content) - self.assertTrue(q("form .has-error")) - - test_data = {"primary_email": "unknown@example.com", - "secondary_emails": nominees[0]} - response = self.client.post(self.private_merge_url, test_data) - self.assertEqual(response.status_code, 200) - q = PyQuery(response.content) - self.assertTrue(q("form .has-error")) - - test_data = {"primary_email": nominees[0], - "secondary_emails": "unknown@example.com"} - response = self.client.post(self.private_merge_url, test_data) - self.assertEqual(response.status_code, 200) - q = PyQuery(response.content) - self.assertTrue(q("form .has-error")) - - test_data = {"secondary_emails": """%s, - %s, - %s""" % (nominees[1], nominees[2], nominees[3]), - "primary_email": nominees[0]} - - response = self.client.post(self.private_merge_url, test_data) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "alert-success") - - self.assertEqual(Nominee.objects.filter(email__address=nominees[1], - duplicated__isnull=False).count(), 1) - self.assertEqual(Nominee.objects.filter(email__address=nominees[2], - duplicated__isnull=False).count(), 1) - self.assertEqual(Nominee.objects.filter(email__address=nominees[3], - duplicated__isnull=False).count(), 1) - - nominee = Nominee.objects.get(email__address=nominees[0]) - - self.assertEqual(Nomination.objects.filter(nominee=nominee).count(), 18) - self.assertEqual(Feedback.objects.nominations().filter(nominees__in=[nominee]).count(), - 18) - self.assertEqual(Feedback.objects.comments().filter(nominees__in=[nominee]).count(), - 4) - self.assertEqual(Feedback.objects.questionnaires().filter(nominees__in=[nominee]).count(), - 4) - - for nominee_email in nominees[1:]: - self.assertEqual(Feedback.objects.nominations().filter(nominees__email__address=nominee_email).count(), - 0) - self.assertEqual(Feedback.objects.comments().filter(nominees__email__address=nominee_email).count(), - 0) - self.assertEqual(Feedback.objects.questionnaires().filter(nominees__email__address=nominee_email).count(), - 0) - - self.assertEqual(NomineePosition.objects.filter(nominee=nominee).count(), 3) - - # Check nominations state - self.assertEqual(NomineePosition.objects.get(position__name='TSV', - nominee=nominee).state.slug, u'accepted') - self.assertEqual(NomineePosition.objects.get(position__name='IAOC', - nominee=nominee).state.slug, u'accepted') - self.assertEqual(NomineePosition.objects.get(position__name='IAB', - nominee=nominee).state.slug, u'declined') - - self.client.logout() - def change_members(self, members): members_emails = u','.join(['%s%s' % (member, EMAIL_DOMAIN) for member in members]) test_data = {'members': members_emails, @@ -425,7 +243,7 @@ class NomcomViewsTest(TestCase): nomcom = get_nomcom_by_year(self.year) count = nomcom.position_set.all().count() login_testing_unauthorized(self, CHAIR_USER, self.edit_position_url) - test_data = {"action" : "add", "name": "testpos", "description": "test description"} + test_data = {"action" : "add", "name": "testpos" } r = self.client.post(self.edit_position_url, test_data) self.assertEqual(r.status_code, 302) self.assertEqual(nomcom.position_set.all().count(), count+1) @@ -469,11 +287,7 @@ class NomcomViewsTest(TestCase): self.nominate_view(public=True,confirmation=True) - self.assertEqual(len(outbox), messages_before + 4) - - self.assertTrue('New person' in outbox[-4]['Subject']) - self.assertTrue('nomcomchair' in outbox[-4]['To']) - self.assertTrue('secretariat' in outbox[-4]['To']) + self.assertEqual(len(outbox), messages_before + 3) self.assertEqual('IETF Nomination Information', outbox[-3]['Subject']) self.assertTrue('nominee' in outbox[-3]['To']) @@ -499,6 +313,42 @@ class NomcomViewsTest(TestCase): return self.nominate_view(public=False) self.client.logout() + def test_public_nominate_newperson(self): + login_testing_unauthorized(self, COMMUNITY_USER, self.public_nominate_url) + + messages_before = len(outbox) + + self.nominate_newperson_view(public=True,confirmation=True) + + self.assertEqual(len(outbox), messages_before + 4) + + self.assertEqual('New person is created', outbox[-4]['Subject']) + self.assertTrue('secretariat' in outbox[-4]['To']) + + self.assertEqual('IETF Nomination Information', outbox[-3]['Subject']) + self.assertTrue('nominee' in outbox[-3]['To']) + + self.assertEqual('Nomination Information', outbox[-2]['Subject']) + self.assertTrue('nomcomchair' in outbox[-2]['To']) + + self.assertEqual('Nomination receipt', outbox[-1]['Subject']) + self.assertTrue('plain' in outbox[-1]['To']) + self.assertTrue(u'Comments with accents äöå' in unicode(outbox[-1].get_payload(decode=True),"utf-8","replace")) + + # Nominate the same person for the same position again without asking for confirmation + + messages_before = len(outbox) + + self.nominate_view(public=True) + self.assertEqual(len(outbox), messages_before + 1) + self.assertEqual('Nomination Information', outbox[-1]['Subject']) + self.assertTrue('nomcomchair' in outbox[-1]['To']) + + def test_private_nominate_newperson(self): + self.access_member_url(self.private_nominate_url) + return self.nominate_newperson_view(public=False) + self.client.logout() + def test_public_nominate_with_automatic_questionnaire(self): nomcom = get_nomcom_by_year(self.year) nomcom.send_questionnaire = True @@ -506,15 +356,23 @@ class NomcomViewsTest(TestCase): login_testing_unauthorized(self, COMMUNITY_USER, self.public_nominate_url) empty_outbox() self.nominate_view(public=True) - self.assertEqual(len(outbox), 4) + self.assertEqual(len(outbox), 3) # test_public_nominate checks the other messages - self.assertTrue('Questionnaire' in outbox[2]['Subject']) - self.assertTrue('nominee@' in outbox[2]['To']) + self.assertTrue('Questionnaire' in outbox[1]['Subject']) + self.assertTrue('nominee@' in outbox[1]['To']) def nominate_view(self, *args, **kwargs): public = kwargs.pop('public', True) + searched_email = kwargs.pop('searched_email', None) nominee_email = kwargs.pop('nominee_email', u'nominee@example.com') + if not searched_email: + searched_email = Email.objects.filter(address=nominee_email).first() + if not searched_email: + searched_email = EmailFactory(address=nominee_email,primary=True) + if not searched_email.person: + searched_email.person = PersonFactory() + searched_email.save() nominator_email = kwargs.pop('nominator_email', "%s%s" % (COMMUNITY_USER, EMAIL_DOMAIN)) position_name = kwargs.pop('position', 'IAOC') confirmation = kwargs.pop('confirmation', False) @@ -540,6 +398,72 @@ class NomcomViewsTest(TestCase): q = PyQuery(response.content) self.assertEqual(len(q("#nominate-form")), 1) + position = Position.objects.get(name=position_name) + comments = u'Test nominate view. Comments with accents äöåÄÖÅ éáíóú âêîôû ü àèìòù.' + candidate_phone = u'123456' + + test_data = {'searched_email': searched_email.pk, + 'candidate_phone': candidate_phone, + 'position': position.id, + 'qualifications': comments, + 'confirmation': confirmation} + if not public: + test_data['nominator_email'] = nominator_email + + response = self.client.post(nominate_url, test_data) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertContains(response, "alert-success") + + # check objects + nominee = Nominee.objects.get(email=searched_email) + NomineePosition.objects.get(position=position, nominee=nominee) + feedback = Feedback.objects.filter(positions__in=[position], + nominees__in=[nominee], + type=FeedbackTypeName.objects.get(slug='nomina')).latest('id') + if public: + self.assertEqual(feedback.author, nominator_email) + + # to check feedback comments are saved like enrypted data + self.assertNotEqual(feedback.comments, comments) + + self.assertEqual(check_comments(feedback.comments, comments, self.privatekey_file), True) + Nomination.objects.get(position=position, + candidate_name=nominee.person.plain_name(), + candidate_email=searched_email.address, + candidate_phone=candidate_phone, + nominee=nominee, + comments=feedback, + nominator_email="%s%s" % (COMMUNITY_USER, EMAIL_DOMAIN)) + + def nominate_newperson_view(self, *args, **kwargs): + public = kwargs.pop('public', True) + nominee_email = kwargs.pop('nominee_email', u'nominee@example.com') + nominator_email = kwargs.pop('nominator_email', "%s%s" % (COMMUNITY_USER, EMAIL_DOMAIN)) + position_name = kwargs.pop('position', 'IAOC') + confirmation = kwargs.pop('confirmation', False) + + if public: + nominate_url = self.public_nominate_newperson_url + else: + nominate_url = self.private_nominate_newperson_url + response = self.client.get(nominate_url) + self.assertEqual(response.status_code, 200) + + nomcom = get_nomcom_by_year(self.year) + if not nomcom.public_key: + q = PyQuery(response.content) + self.assertEqual(len(q("#nominate-form")), 0) + + # save the cert file in tmp + nomcom.public_key.storage.location = tempfile.gettempdir() + nomcom.public_key.save('cert', File(open(self.cert_file.name, 'r'))) + + response = self.client.get(nominate_url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertEqual(len(q("#nominate-form")), 1) + position = Position.objects.get(name=position_name) candidate_email = nominee_email candidate_name = u'nominee' @@ -550,12 +474,13 @@ class NomcomViewsTest(TestCase): 'candidate_email': candidate_email, 'candidate_phone': candidate_phone, 'position': position.id, - 'comments': comments, + 'qualifications': comments, 'confirmation': confirmation} if not public: test_data['nominator_email'] = nominator_email - response = self.client.post(nominate_url, test_data) + response = self.client.post(nominate_url, test_data,follow=True) + self.assertTrue(response.redirect_chain) self.assertEqual(response.status_code, 200) q = PyQuery(response.content) self.assertContains(response, "alert-success") @@ -644,10 +569,10 @@ class NomcomViewsTest(TestCase): self.feedback_view(public=True,confirmation=True) # feedback_view does a nomination internally: there is a lot of email related to that - tested elsewhere # We're interested in the confirmation receipt here - self.assertEqual(len(outbox),4) - self.assertEqual('NomCom comment confirmation', outbox[3]['Subject']) - self.assertTrue('plain' in outbox[3]['To']) - self.assertTrue(u'Comments with accents äöå' in unicode(outbox[3].get_payload(decode=True),"utf-8","replace")) + self.assertEqual(len(outbox),3) + self.assertEqual('NomCom comment confirmation', outbox[2]['Subject']) + self.assertTrue('plain' in outbox[2]['To']) + self.assertTrue(u'Comments with accents äöå' in unicode(outbox[2].get_payload(decode=True),"utf-8","replace")) empty_outbox() self.feedback_view(public=True) @@ -657,7 +582,6 @@ class NomcomViewsTest(TestCase): def test_private_feedback(self): self.access_member_url(self.private_feedback_url) return self.feedback_view(public=False) - self.client.logout() def feedback_view(self, *args, **kwargs): public = kwargs.pop('public', True) @@ -688,11 +612,16 @@ class NomcomViewsTest(TestCase): response = self.client.get(feedback_url) self.assertEqual(response.status_code, 200) - self.assertContains(response, "feedbackform") + self.assertNotContains(response, "feedbackform") position = Position.objects.get(name=position_name) nominee = Nominee.objects.get(email__address=nominee_email) + feedback_url += "?nominee=%d&position=%d" % (nominee.id, position.id) + response = self.client.get(feedback_url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "feedbackform") + comments = u'Test feedback view. Comments with accents äöåÄÖÅ éáíóú âêîôû ü àèìòù.' test_data = {'comments': comments, @@ -705,8 +634,6 @@ class NomcomViewsTest(TestCase): test_data['nominator_email'] = nominator_email test_data['nominator_name'] = nominator_email - feedback_url += "?nominee=%d&position=%d" % (nominee.id, position.id) - nominee_position = NomineePosition.objects.get(nominee=nominee, position=position) state = nominee_position.state @@ -722,6 +649,7 @@ class NomcomViewsTest(TestCase): response = self.client.post(feedback_url, test_data) self.assertEqual(response.status_code, 200) self.assertContains(response, "alert-success") + self.assertNotContains(response, "feedbackform") ## check objects feedback = Feedback.objects.filter(positions__in=[position], @@ -745,16 +673,12 @@ class NomineePositionStateSaveTest(TestCase): """Tests for the NomineePosition save override method""" def setUp(self): - self.nomcom_public_keys_dir = os.path.abspath("tmp-nomcom-public-keys-dir") - if not os.path.exists(self.nomcom_public_keys_dir): - os.mkdir(self.nomcom_public_keys_dir) - settings.NOMCOM_PUBLIC_KEYS_DIR = self.nomcom_public_keys_dir - + build_test_public_keys_dir(self) nomcom_test_data() self.nominee = Nominee.objects.get(email__person__user__username=COMMUNITY_USER) def tearDown(self): - shutil.rmtree(self.nomcom_public_keys_dir) + clean_test_public_keys_dir(self) def test_state_autoset(self): """Verify state is autoset correctly""" @@ -784,16 +708,13 @@ class NomineePositionStateSaveTest(TestCase): class FeedbackTest(TestCase): def setUp(self): - self.nomcom_public_keys_dir = os.path.abspath("tmp-nomcom-public-keys-dir") - if not os.path.exists(self.nomcom_public_keys_dir): - os.mkdir(self.nomcom_public_keys_dir) - settings.NOMCOM_PUBLIC_KEYS_DIR = self.nomcom_public_keys_dir + build_test_public_keys_dir(self) nomcom_test_data() self.cert_file, self.privatekey_file = get_cert_files() def tearDown(self): - shutil.rmtree(self.nomcom_public_keys_dir) + clean_test_public_keys_dir(self) def test_encrypted_comments(self): @@ -820,11 +741,7 @@ class FeedbackTest(TestCase): class ReminderTest(TestCase): def setUp(self): - self.nomcom_public_keys_dir = os.path.abspath("tmp-nomcom-public-keys-dir") - if not os.path.exists(self.nomcom_public_keys_dir): - os.mkdir(self.nomcom_public_keys_dir) - settings.NOMCOM_PUBLIC_KEYS_DIR = self.nomcom_public_keys_dir - + build_test_public_keys_dir(self) nomcom_test_data() self.nomcom = get_nomcom_by_year(NOMCOM_YEAR) self.cert_file, self.privatekey_file = get_cert_files() @@ -838,20 +755,22 @@ class ReminderTest(TestCase): today = datetime.date.today() t_minus_3 = today - datetime.timedelta(days=3) t_minus_4 = today - datetime.timedelta(days=4) - n = get_or_create_nominee(self.nomcom,"Nominee 1","nominee1@example.org",gen,None) + e1 = EmailFactory(address="nominee1@example.org",person=PersonFactory(name=u"Nominee 1")) + e2 = EmailFactory(address="nominee2@example.org",person=PersonFactory(name=u"Nominee 2")) + n = make_nomineeposition(self.nomcom,e1.person,gen,None) np = n.nomineeposition_set.get(position=gen) np.time = t_minus_3 np.save() - n = get_or_create_nominee(self.nomcom,"Nominee 1","nominee1@example.org",iab,None) + n = make_nomineeposition(self.nomcom,e1.person,iab,None) np = n.nomineeposition_set.get(position=iab) np.state = NomineePositionStateName.objects.get(slug='accepted') np.time = t_minus_3 np.save() - n = get_or_create_nominee(self.nomcom,"Nominee 2","nominee2@example.org",rai,None) + n = make_nomineeposition(self.nomcom,e2.person,rai,None) np = n.nomineeposition_set.get(position=rai) np.time = t_minus_4 np.save() - n = get_or_create_nominee(self.nomcom,"Nominee 2","nominee2@example.org",gen,None) + n = make_nomineeposition(self.nomcom,e2.person,gen,None) np = n.nomineeposition_set.get(position=gen) np.state = NomineePositionStateName.objects.get(slug='accepted') np.time = t_minus_4 @@ -864,7 +783,7 @@ class ReminderTest(TestCase): feedback.nominees.add(n) def tearDown(self): - shutil.rmtree(self.nomcom_public_keys_dir) + clean_test_public_keys_dir(self) def test_is_time_to_send(self): self.nomcom.reminder_interval = 4 @@ -917,3 +836,732 @@ class ReminderTest(TestCase): self.assertEqual(len(outbox), messages_before + 1) self.assertTrue('nominee1@' in outbox[-1]['To']) +class InactiveNomcomTests(TestCase): + + def setUp(self): + build_test_public_keys_dir(self) + self.nc = NomComFactory.create(**nomcom_kwargs_for_year(group__state_id='conclude')) + self.plain_person = PersonFactory.create() + self.chair = self.nc.group.role_set.filter(name='chair').first().person + self.member = self.nc.group.role_set.filter(name='member').first().person + + def tearDown(self): + clean_test_public_keys_dir(self) + + def test_feedback_closed(self): + for view in ['nomcom_public_feedback', 'nomcom_private_feedback']: + url = reverse(view, kwargs={'year': self.nc.year()}) + who = self.plain_person if 'public' in view else self.member + login_testing_unauthorized(self, who.user.username, url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertTrue( '(Concluded)' in q('h1').text()) + self.assertTrue( 'closed' in q('#instructions').text()) + self.assertTrue( q('#nominees a') ) + self.assertFalse( q('#nominees a[href]') ) + + url += "?nominee=%d&position=%d" % (self.nc.nominee_set.first().id, self.nc.nominee_set.first().nomineeposition_set.first().position.id) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertFalse( q('#feedbackform')) + + empty_outbox() + fb_before = self.nc.feedback_set.count() + test_data = {'comments': u'Test feedback view. Comments with accents äöåÄÖÅ éáíóú âêîôû ü àèìòù.', + 'nominator_email': self.plain_person.email_set.first().address, + 'confirmation': True} + response = self.client.post(url, test_data) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertTrue( 'closed' in q('#instructions').text()) + self.assertEqual( len(outbox), 0 ) + self.assertEqual( fb_before, self.nc.feedback_set.count() ) + + def test_nominations_closed(self): + for view in ['nomcom_public_nominate', 'nomcom_private_nominate']: + url = reverse(view, kwargs={'year': self.nc.year() }) + who = self.plain_person if 'public' in view else self.member + login_testing_unauthorized(self, who.user.username, url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertTrue( '(Concluded)' in q('h1').text()) + self.assertTrue( 'closed' in q('.alert-warning').text()) + + def test_acceptance_closed(self): + today = datetime.date.today().strftime('%Y%m%d') + pid = self.nc.position_set.first().nomineeposition_set.first().id + url = reverse('nomcom_process_nomination_status', kwargs = { + 'year' : self.nc.year(), + 'nominee_position_id' : pid, + 'state' : 'accepted', + 'date' : today, + 'hash' : get_hash_nominee_position(today,pid), + }) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_can_view_but_cannot_edit_nomcom_settings(self): + url = reverse('nomcom_edit_nomcom',kwargs={'year':self.nc.year() }) + login_testing_unauthorized(self, self.chair.user.username, url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response = self.client.post(url,{}) + self.assertEqual(response.status_code, 403) + + def test_cannot_classify_feedback(self): + url = reverse('nomcom_view_feedback_pending',kwargs={'year':self.nc.year() }) + login_testing_unauthorized(self, self.chair.user.username, url) + provide_private_key_to_test_client(self) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + response = self.client.post(url,{}) + self.assertEqual(response.status_code, 403) + + def test_cannot_modify_nominees(self): + url = reverse('nomcom_private_index', kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self, self.chair.user.username, url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertFalse( q('#batch-action-form')) + test_data = {"action": "set_as_pending", + "selected": [1]} + response = self.client.post(url, test_data) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertTrue('not active' in q('.alert-warning').text() ) + + def test_email_pasting_closed(self): + url = reverse('nomcom_private_feedback_email', kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self, self.chair.user.username, url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertFalse( q('#paste-email-feedback-form')) + test_data = {"email_text": "some garbage text", + } + response = self.client.post(url, test_data) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertTrue('not active' in q('.alert-warning').text() ) + + def test_questionnaire_entry_closed(self): + url = reverse('nomcom_private_questionnaire', kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self, self.chair.user.username, url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertFalse( q('#questionnaireform')) + response = self.client.post(url, {}) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertTrue('not active' in q('.alert-warning').text() ) + + def _test_send_reminders_closed(self,rtype): + url = reverse('nomcom_send_reminder_mail', kwargs={'year':self.nc.year(),'type':rtype }) + login_testing_unauthorized(self, self.chair.user.username, url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertFalse( q('#reminderform')) + response = self.client.post(url, {}) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertTrue('not active' in q('.alert-warning').text() ) + + def test_send_accept_reminders_closed(self): + self._test_send_reminders_closed('accept') + + def test_send_questionnaire_reminders_closed(self): + self._test_send_reminders_closed('questionnaire') + + def test_merge_closed(self): + url = reverse('nomcom_private_merge', kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self, self.chair.user.username, url) + response = self.client.get(url) + q = PyQuery(response.content) + self.assertFalse( q('#mergeform')) + response = self.client.post(url, {}) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertTrue('not active' in q('.alert-warning').text() ) + + def test_cannot_edit_position(self): + url = reverse('nomcom_edit_position',kwargs={'year':self.nc.year(),'position_id':self.nc.position_set.first().id}) + login_testing_unauthorized(self, self.chair.user.username, url) + provide_private_key_to_test_client(self) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + response = self.client.post(url,{}) + self.assertEqual(response.status_code, 403) + + def test_cannot_add_position(self): + url = reverse('nomcom_add_position',kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self, self.chair.user.username, url) + provide_private_key_to_test_client(self) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + response = self.client.post(url,{}) + self.assertEqual(response.status_code, 403) + + def test_cannot_delete_position(self): + url = reverse('nomcom_remove_position',kwargs={'year':self.nc.year(),'position_id':self.nc.position_set.first().id}) + login_testing_unauthorized(self, self.chair.user.username, url) + provide_private_key_to_test_client(self) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + response = self.client.post(url,{}) + self.assertEqual(response.status_code, 403) + + def test_can_view_but_not_edit_templates(self): + template = DBTemplateFactory.create(group=self.nc.group, + title='Test template', + path='/nomcom/'+self.nc.group.acronym+'/test', + variables='', + type_id='plain', + content='test content') + url = reverse('nomcom_edit_template',kwargs={'year':self.nc.year(), 'template_id':template.id}) + login_testing_unauthorized(self, self.chair.user.username, url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertFalse( q('#templateform') ) + +class FeedbackLastSeenTests(TestCase): + + def setUp(self): + build_test_public_keys_dir(self) + self.nc = NomComFactory.create(**nomcom_kwargs_for_year()) + self.author = PersonFactory.create().email_set.first().address + self.member = self.nc.group.role_set.filter(name='member').first().person + self.nominee = self.nc.nominee_set.first() + self.position = self.nc.position_set.first() + for type_id in ['comment','nomina','questio']: + f = FeedbackFactory.create(author=self.author,nomcom=self.nc,type_id=type_id) + f.positions.add(self.position) + f.nominees.add(self.nominee) + now = datetime.datetime.now() + self.hour_ago = now - datetime.timedelta(hours=1) + self.half_hour_ago = now - datetime.timedelta(minutes=30) + self.second_from_now = now + datetime.timedelta(seconds=1) + + def tearDown(self): + clean_test_public_keys_dir(self) + + def test_feedback_index_badges(self): + url = reverse('nomcom_view_feedback',kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self, self.member.user.username, url) + provide_private_key_to_test_client(self) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + q = PyQuery(response.content) + self.assertEqual( len(q('.label-success')), 3 ) + + f = self.nc.feedback_set.first() + f.time = self.hour_ago + f.save() + FeedbackLastSeen.objects.create(reviewer=self.member,nominee=self.nominee) + FeedbackLastSeen.objects.update(time=self.half_hour_ago) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + q = PyQuery(response.content) + self.assertEqual( len(q('.label-success')), 2 ) + + FeedbackLastSeen.objects.update(time=self.second_from_now) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + q = PyQuery(response.content) + self.assertEqual( len(q('.label-success')), 0 ) + + def test_feedback_nominee_badges(self): + url = reverse('nomcom_view_feedback_nominee',kwargs={'year':self.nc.year(),'nominee_id':self.nominee.id}) + login_testing_unauthorized(self, self.member.user.username, url) + provide_private_key_to_test_client(self) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + q = PyQuery(response.content) + self.assertEqual( len(q('.label-success')), 3 ) + + f = self.nc.feedback_set.first() + f.time = self.hour_ago + f.save() + FeedbackLastSeen.objects.create(reviewer=self.member,nominee=self.nominee) + FeedbackLastSeen.objects.update(time=self.half_hour_ago) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + q = PyQuery(response.content) + self.assertEqual( len(q('.label-success')), 2 ) + + FeedbackLastSeen.objects.update(time=self.second_from_now) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + q = PyQuery(response.content) + self.assertEqual( len(q('.label-success')), 0 ) + +class NewActiveNomComTests(TestCase): + + def setUp(self): + build_test_public_keys_dir(self) + self.nc = NomComFactory.create(**nomcom_kwargs_for_year()) + self.chair = self.nc.group.role_set.filter(name='chair').first().person + + def tearDown(self): + clean_test_public_keys_dir(self) + + def test_help(self): + url = reverse('nomcom_chair_help',kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self, self.chair.user.username, url) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + + def test_accept_reject_nomination_edges(self): + + np = self.nc.nominee_set.first().nomineeposition_set.first() + + kwargs={'year':self.nc.year(), + 'nominee_position_id':np.id, + 'state':'accepted', + 'date':np.time.strftime("%Y%m%d"), + 'hash':get_hash_nominee_position(np.time.strftime("%Y%m%d"),np.id), + } + url = reverse('nomcom_process_nomination_status', kwargs=kwargs) + response = self.client.get(url) + self.assertEqual(response.status_code,403) + self.assertTrue('already was' in unicontent(response)) + + settings.DAYS_TO_EXPIRE_NOMINATION_LINK = 2 + np.time = np.time - datetime.timedelta(days=3) + np.save() + kwargs['date'] = np.time.strftime("%Y%m%d") + kwargs['hash'] = get_hash_nominee_position(np.time.strftime("%Y%m%d"),np.id) + url = reverse('nomcom_process_nomination_status', kwargs=kwargs) + response = self.client.get(url) + self.assertEqual(response.status_code,403) + self.assertTrue('Link expired' in unicontent(response)) + + kwargs['hash'] = 'bad' + url = reverse('nomcom_process_nomination_status', kwargs=kwargs) + response = self.client.get(url) + self.assertEqual(response.status_code,403) + self.assertTrue('Bad hash!' in unicontent(response)) + + def test_accept_reject_nomination_comment(self): + np = self.nc.nominee_set.first().nomineeposition_set.first() + hash = get_hash_nominee_position(np.time.strftime("%Y%m%d"),np.id) + url = reverse('nomcom_process_nomination_status', + kwargs={'year':self.nc.year(), + 'nominee_position_id':np.id, + 'state':'accepted', + 'date':np.time.strftime("%Y%m%d"), + 'hash':hash, + } + ) + np.state_id='pending' + np.save() + response = self.client.get(url) + self.assertEqual(response.status_code,200) + feedback_count_before = Feedback.objects.count() + response = self.client.post(url,{}) + # This view uses Yaco-style POST handling + self.assertEqual(response.status_code,200) + self.assertEqual(Feedback.objects.count(),feedback_count_before) + np.state_id='pending' + np.save() + response = self.client.post(url,{'comments':'A nonempty comment'}) + self.assertEqual(response.status_code,200) + self.assertEqual(Feedback.objects.count(),feedback_count_before+1) + + def test_provide_private_key(self): + url = reverse('nomcom_private_key',kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + response = self.client.post(url,{'key':key}) + self.assertEqual(response.status_code,302) + + def test_email_pasting(self): + url = reverse('nomcom_private_feedback_email',kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + fb_count_before = Feedback.objects.count() + response = self.client.post(url,{'email_text':"""To: rjsparks@nostrum.com +From: Robert Sparks +Subject: Junk message for feedback testing +Message-ID: <566F2FE5.1050401@nostrum.com> +Date: Mon, 14 Dec 2015 15:08:53 -0600 +Content-Type: text/plain; charset=utf-8; format=flowed +Content-Transfer-Encoding: 7bit + +Junk body for testing + +"""}) + self.assertEqual(response.status_code,200) + self.assertEqual(Feedback.objects.count(),fb_count_before+1) + + def test_simple_feedback_pending(self): + url = reverse('nomcom_view_feedback_pending',kwargs={'year':self.nc.year() }) + login_testing_unauthorized(self, self.chair.user.username, url) + provide_private_key_to_test_client(self) + + # test simple classification when there's only one thing to classify + + # junk is the only category you can set directly from the first form the view presents + fb = FeedbackFactory(nomcom=self.nc,type_id=None) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + + response = self.client.post(url, {'form-TOTAL_FORMS': 1, + 'form-INITIAL_FORMS': 1, + 'form-0-id': fb.id, + 'form-0-type': 'junk', + }) + self.assertEqual(response.status_code,302) + fb = Feedback.objects.get(id=fb.id) + self.assertEqual(fb.type_id,'junk') + + # comments, nominations, and questionnare responses are catagorized via a second + # formset presented by the view (signaled by having 'end' appear in the POST) + fb = FeedbackFactory(nomcom=self.nc,type_id=None) + np = NomineePosition.objects.filter(position__nomcom = self.nc,state='accepted').first() + fb_count_before = np.nominee.feedback_set.count() + response = self.client.post(url, {'form-TOTAL_FORMS':1, + 'form-INITIAL_FORMS':1, + 'end':'Save feedback', + 'form-0-id': fb.id, + 'form-0-type': 'comment', + 'form-0-nominee': '%s_%s'%(np.position.id,np.nominee.id), + }) + self.assertEqual(response.status_code,302) + fb = Feedback.objects.get(id=fb.id) + self.assertEqual(fb.type_id,'comment') + self.assertEqual(np.nominee.feedback_set.count(),fb_count_before+1) + + fb = FeedbackFactory(nomcom=self.nc,type_id=None) + nominee = self.nc.nominee_set.first() + position = self.nc.position_set.exclude(nomineeposition__nominee=nominee).first() + self.assertIsNotNone(position) + fb_count_before = nominee.feedback_set.count() + response = self.client.post(url, {'form-TOTAL_FORMS':1, + 'form-INITIAL_FORMS':1, + 'end':'Save feedback', + 'form-0-id': fb.id, + 'form-0-type': 'nomina', + 'form-0-position': position.id, + 'form-0-searched_email' : nominee.email.address, + }) + self.assertEqual(response.status_code,302) + fb = Feedback.objects.get(id=fb.id) + self.assertEqual(fb.type_id,'nomina') + self.assertEqual(nominee.feedback_set.count(),fb_count_before+1) + + # Classify a newperson + fb = FeedbackFactory(nomcom=self.nc,type_id=None) + position = self.nc.position_set.first() + response = self.client.post(url, {'form-TOTAL_FORMS':1, + 'form-INITIAL_FORMS':1, + 'end':'Save feedback', + 'form-0-id': fb.id, + 'form-0-type': 'nomina', + 'form-0-position': position.id, + 'form-0-candidate_email' : 'newperson@example.com', + 'form-0-candidate_name' : 'New Person', + }) + self.assertEqual(response.status_code,302) + fb = Feedback.objects.get(id=fb.id) + self.assertEqual(fb.type_id,'nomina') + self.assertTrue(fb.nominees.filter(person__name='New Person').exists()) + + # check for failure when trying to add a newperson that already exists + + fb = FeedbackFactory(nomcom=self.nc,type_id=None) + position = self.nc.position_set.all()[1] + nominee = self.nc.nominee_set.get(person__email__address='newperson@example.com') + fb_count_before = nominee.feedback_set.count() + response = self.client.post(url, {'form-TOTAL_FORMS':1, + 'form-INITIAL_FORMS':1, + 'end':'Save feedback', + 'form-0-id': fb.id, + 'form-0-type': 'nomina', + 'form-0-position': position.id, + 'form-0-candidate_email' : 'newperson@example.com', + 'form-0-candidate_name' : 'New Person', + }) + self.assertEqual(response.status_code,200) + self.assertTrue('already exists' in unicontent(response)) + fb = Feedback.objects.get(id=fb.id) + self.assertEqual(fb.type_id,None) + self.assertEqual(nominee.feedback_set.count(),fb_count_before) + + fb = FeedbackFactory(nomcom=self.nc,type_id=None) + np = NomineePosition.objects.filter(position__nomcom = self.nc,state='accepted').first() + fb_count_before = np.nominee.feedback_set.count() + response = self.client.post(url, {'form-TOTAL_FORMS':1, + 'form-INITIAL_FORMS':1, + 'end':'Save feedback', + 'form-0-id': fb.id, + 'form-0-type': 'questio', + 'form-0-nominee' : '%s_%s'%(np.position.id,np.nominee.id), + }) + self.assertEqual(response.status_code,302) + fb = Feedback.objects.get(id=fb.id) + self.assertEqual(fb.type_id,'questio') + self.assertEqual(np.nominee.feedback_set.count(),fb_count_before+1) + + def test_complicated_feedback_pending(self): + url = reverse('nomcom_view_feedback_pending',kwargs={'year':self.nc.year() }) + login_testing_unauthorized(self, self.chair.user.username, url) + provide_private_key_to_test_client(self) + + # Test having multiple things to classify + # The view has some complicated to handle having some forms in the initial form formset + # being categorized as 'junk' and others being categorized as something that requires + # more information. The second formset presented will have forms for any others initially + # categorized as nominations, then a third formset will be presented with any that were + # initially categorized as comments or questionnaire responses. The following exercises + # all the gears that glue these three formset presentations together. + + fb0 = FeedbackFactory(nomcom=self.nc,type_id=None) + fb1 = FeedbackFactory(nomcom=self.nc,type_id=None) + fb2 = FeedbackFactory(nomcom=self.nc,type_id=None) + nominee = self.nc.nominee_set.first() + new_position_for_nominee = self.nc.position_set.exclude(nomineeposition__nominee=nominee).first() + + # Initial formset + response = self.client.post(url, {'form-TOTAL_FORMS': 3, + 'form-INITIAL_FORMS': 3, + 'form-0-id': fb0.id, + 'form-0-type': 'junk', + 'form-1-id': fb1.id, + 'form-1-type': 'nomina', + 'form-2-id': fb2.id, + 'form-2-type': 'comment', + }) + self.assertEqual(response.status_code,200) # Notice that this is not a 302 + fb0 = Feedback.objects.get(id=fb0.id) + self.assertEqual(fb0.type_id,'junk') + q = PyQuery(response.content) + self.assertEqual(q('input[name=\"form-0-type\"]').attr['value'],'nomina') + self.assertEqual(q('input[name=\"extra_ids\"]').attr['value'],'%s:comment' % fb2.id) + + # Second formset + response = self.client.post(url, {'form-TOTAL_FORMS':1, + 'form-INITIAL_FORMS':1, + 'end':'Save feedback', + 'form-0-id': fb1.id, + 'form-0-type': 'nomina', + 'form-0-position': new_position_for_nominee.id, + 'form-0-candidate_name' : 'Totally New Person', + 'form-0-candidate_email': 'totallynew@example.org', + 'extra_ids': '%s:comment' % fb2.id, + }) + self.assertEqual(response.status_code,200) # Notice that this is also is not a 302 + q = PyQuery(response.content) + self.assertEqual(q('input[name=\"form-0-type\"]').attr['value'],'comment') + self.assertFalse(q('input[name=\"extra_ids\"]')) + fb1 = Feedback.objects.get(id=fb1.id) + self.assertEqual(fb1.type_id,'nomina') + + # Exercising the resulting third formset is identical to the simple test above + # that categorizes a single thing as a comment. Note that it returns a 302. + + # There is yet another code-path for transitioning to the second form when + # nothing was classified as a nomination. + fb0 = FeedbackFactory(nomcom=self.nc,type_id=None) + fb1 = FeedbackFactory(nomcom=self.nc,type_id=None) + response = self.client.post(url, {'form-TOTAL_FORMS': 2, + 'form-INITIAL_FORMS': 2, + 'form-0-id': fb0.id, + 'form-0-type': 'junk', + 'form-1-id': fb1.id, + 'form-1-type': 'comment', + }) + self.assertEqual(response.status_code,200) + q = PyQuery(response.content) + self.assertEqual(q('input[name=\"form-0-type\"]').attr['value'],'comment') + self.assertFalse(q('input[name=\"extra_ids\"]')) + + def test_feedback_unrelated(self): + FeedbackFactory(nomcom=self.nc,type_id='junk') + url=reverse('nomcom_view_feedback_unrelated',kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self,self.chair.user.username,url) + provide_private_key_to_test_client(self) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + + def test_list_templates(self): + DBTemplateFactory.create(group=self.nc.group, + title='Test template', + path='/nomcom/'+self.nc.group.acronym+'/test', + variables='', + type_id='plain', + content='test content') + url=reverse('nomcom_list_templates',kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + + def test_edit_templates(self): + template = DBTemplateFactory.create(group=self.nc.group, + title='Test template', + path='/nomcom/'+self.nc.group.acronym+'/test', + variables='', + type_id='plain', + content='test content') + url=reverse('nomcom_edit_template',kwargs={'year':self.nc.year(),'template_id':template.id}) + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + response = self.client.post(url,{'content': 'more interesting test content'}) + self.assertEqual(response.status_code,302) + template = DBTemplate.objects.get(id=template.id) + self.assertEqual('more interesting test content',template.content) + + def test_list_positions(self): + url = reverse('nomcom_list_positions',kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + + def test_remove_position(self): + position = self.nc.position_set.filter(nomineeposition__isnull=False).first() + f = FeedbackFactory(nomcom=self.nc) + f.positions.add(position) + url = reverse('nomcom_remove_position',kwargs={'year':self.nc.year(),'position_id':position.id}) + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + q = PyQuery(response.content) + self.assertTrue(any(['likely to be harmful' in x.text for x in q('.alert-warning')])) + response = self.client.post(url,{'remove':position.id}) + self.assertEqual(response.status_code, 302) + self.assertFalse(self.nc.position_set.filter(id=position.id)) + + def test_remove_invalid_position(self): + no_such_position_id = self.nc.position_set.aggregate(Max('id'))['id__max']+1 + url = reverse('nomcom_remove_position',kwargs={'year':self.nc.year(),'position_id':no_such_position_id}) + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_edit_position(self): + position = self.nc.position_set.filter(is_open=True).first() + url = reverse('nomcom_edit_position',kwargs={'year':self.nc.year(),'position_id':position.id}) + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response = self.client.post(url,{'name':'more interesting test name'}) + self.assertEqual(response.status_code, 302) + position = Position.objects.get(id=position.id) + self.assertEqual('more interesting test name',position.name) + self.assertFalse(position.is_open) + + def test_edit_invalid_position(self): + no_such_position_id = self.nc.position_set.aggregate(Max('id'))['id__max']+1 + url = reverse('nomcom_edit_position',kwargs={'year':self.nc.year(),'position_id':no_such_position_id}) + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_edit_nominee(self): + nominee = self.nc.nominee_set.first() + new_email = EmailFactory(person=nominee.person) + url = reverse('nomcom_edit_nominee',kwargs={'year':self.nc.year(),'nominee_id':nominee.id}) + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response = self.client.post(url,{'nominee_email':new_email.address}) + self.assertEqual(response.status_code, 302) + nominee = self.nc.nominee_set.first() + self.assertEqual(nominee.email,new_email) + + def test_request_merge(self): + nominee1, nominee2 = self.nc.nominee_set.all()[:2] + url = reverse('nomcom_private_merge',kwargs={'year':self.nc.year()}) + login_testing_unauthorized(self,self.chair.user.username,url) + empty_outbox() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response = self.client.post(url,{'primary_person':nominee1.person.pk, + 'duplicate_persons':[nominee1.person.pk]}) + self.assertEqual(response.status_code, 200) + self.assertTrue('must not also be listed as a duplicate' in unicontent(response)) + response = self.client.post(url,{'primary_person':nominee1.person.pk, + 'duplicate_persons':[nominee2.person.pk]}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(outbox),1) + self.assertTrue(all([str(x.person.pk) in unicode(outbox[0]) for x in [nominee1,nominee2]])) + + +class NomComIndexTests(TestCase): + def setUp(self): + for year in range(2000,2014): + NomComFactory.create(**nomcom_kwargs_for_year(year=year,populate_positions=False,populate_personnel=False)) + + def testIndex(self): + url = reverse('ietf.nomcom.views.index') + response = self.client.get(url) + self.assertEqual(response.status_code,200) + +class NoPublicKeyTests(TestCase): + def setUp(self): + self.nc = NomComFactory.create(**nomcom_kwargs_for_year(public_key=None)) + self.chair = self.nc.group.role_set.filter(name='chair').first().person + + def do_common_work(self,url,expected_form): + login_testing_unauthorized(self,self.chair.user.username,url) + response = self.client.get(url) + self.assertEqual(response.status_code,200) + q=PyQuery(response.content) + text_bits = [x.xpath('./text()') for x in q('.alert-warning')] + flat_text_bits = [item for sublist in text_bits for item in sublist] + self.assertTrue(any(['not yet' in y for y in flat_text_bits])) + self.assertEqual(bool(q('form:not(.navbar-form)')),expected_form) + self.client.logout() + + def test_not_yet(self): + # Warn reminder mail + self.do_common_work(reverse('nomcom_send_reminder_mail',kwargs={'year':self.nc.year(),'type':'accept'}),True) + # No nominations + self.do_common_work(reverse('nomcom_private_nominate',kwargs={'year':self.nc.year()}),False) + # No feedback + self.do_common_work(reverse('nomcom_private_feedback',kwargs={'year':self.nc.year()}),False) + # No feedback email + self.do_common_work(reverse('nomcom_private_feedback_email',kwargs={'year':self.nc.year()}),False) + # No questionnaire responses + self.do_common_work(reverse('nomcom_private_questionnaire',kwargs={'year':self.nc.year()}),False) + # Warn on edit nomcom + self.do_common_work(reverse('nomcom_edit_nomcom',kwargs={'year':self.nc.year()}),True) + +class MergePersonTests(TestCase): + def setUp(self): + build_test_public_keys_dir(self) + self.nc = NomComFactory(**nomcom_kwargs_for_year()) + self.author = PersonFactory.create().email_set.first().address + self.nominee1, self.nominee2 = self.nc.nominee_set.all()[:2] + self.person1, self.person2 = self.nominee1.person, self.nominee2.person + self.position = self.nc.position_set.first() + for nominee in [self.nominee1, self.nominee2]: + f = FeedbackFactory.create(author=self.author,nomcom=self.nc,type_id='nomina') + f.positions.add(self.position) + f.nominees.add(nominee) + UserFactory(is_superuser=True) + + def tearDown(self): + clean_test_public_keys_dir(self) + + def test_merge_person(self): + person1, person2 = [nominee.person for nominee in self.nc.nominee_set.all()[:2]] + stream = StringIO.StringIO() + + self.assertEqual(self.nc.nominee_set.count(),4) + self.assertEqual(self.nominee1.feedback_set.count(),1) + self.assertEqual(self.nominee2.feedback_set.count(),1) + merge_persons(person1,person2,stream) + self.assertEqual(self.nc.nominee_set.count(),3) + self.assertEqual(self.nc.nominee_set.get(pk=self.nominee2.pk).feedback_set.count(),2) + self.assertFalse(self.nc.nominee_set.filter(pk=self.nominee1.pk).exists()) + diff --git a/ietf/nomcom/urls.py b/ietf/nomcom/urls.py index d50141100..69473b229 100644 --- a/ietf/nomcom/urls.py +++ b/ietf/nomcom/urls.py @@ -1,5 +1,4 @@ from django.conf.urls import patterns, url -from django.views.generic import TemplateView from ietf.nomcom.forms import EditMembersForm, EditMembersFormPreview @@ -8,7 +7,9 @@ urlpatterns = patterns('ietf.nomcom.views', url(r'^ann/$', 'announcements'), url(r'^(?P\d{4})/private/$', 'private_index', name='nomcom_private_index'), url(r'^(?P\d{4})/private/key/$', 'private_key', name='nomcom_private_key'), + url(r'^(?P\d{4})/private/help/$', 'configuration_help', name='nomcom_chair_help'), url(r'^(?P\d{4})/private/nominate/$', 'private_nominate', name='nomcom_private_nominate'), + url(r'^(?P\d{4})/private/nominate/newperson$', 'private_nominate_newperson', name='nomcom_private_nominate_newperson'), url(r'^(?P\d{4})/private/feedback/$', 'private_feedback', name='nomcom_private_feedback'), url(r'^(?P\d{4})/private/feedback-email/$', 'private_feedback_email', name='nomcom_private_feedback_email'), url(r'^(?P\d{4})/private/questionnaire-response/$', 'private_questionnaire', name='nomcom_private_questionnaire'), @@ -22,8 +23,6 @@ urlpatterns = patterns('ietf.nomcom.views', url(r'^(?P\d{4})/private/send-reminder-mail/(?P\w+)/$', 'send_reminder_mail', name='nomcom_send_reminder_mail'), url(r'^(?P\d{4})/private/edit-members/$', EditMembersFormPreview(EditMembersForm), name='nomcom_edit_members'), url(r'^(?P\d{4})/private/edit-nomcom/$', 'edit_nomcom', name='nomcom_edit_nomcom'), - url(r'^(?P\d{4})/private/delete-nomcom/$', 'delete_nomcom', name='nomcom_delete_nomcom'), - url(r'^deleted/$', TemplateView.as_view(template_name='nomcom/deleted.html'), name='nomcom_deleted'), url(r'^(?P\d{4})/private/chair/templates/$', 'list_templates', name='nomcom_list_templates'), url(r'^(?P\d{4})/private/chair/templates/(?P\d+)/$', 'edit_template', name='nomcom_edit_template'), url(r'^(?P\d{4})/private/chair/position/$', 'list_positions', name='nomcom_list_positions'), @@ -37,6 +36,7 @@ urlpatterns = patterns('ietf.nomcom.views', url(r'^(?P\d{4})/questionnaires/$', 'questionnaires', name='nomcom_questionnaires'), url(r'^(?P\d{4})/feedback/$', 'public_feedback', name='nomcom_public_feedback'), url(r'^(?P\d{4})/nominate/$', 'public_nominate', name='nomcom_public_nominate'), + url(r'^(?P\d{4})/nominate/newperson$', 'public_nominate_newperson', name='nomcom_public_nominate_newperson'), url(r'^(?P\d{4})/process-nomination-status/(?P\d+)/(?P[\w]+)/(?P[\d]+)/(?P[a-f0-9]+)/$', 'process_nomination_status', name='nomcom_process_nomination_status'), ) diff --git a/ietf/nomcom/utils.py b/ietf/nomcom/utils.py index 084cf7db7..01dc0f6ce 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -29,7 +29,7 @@ import debug # pyflakes:ignore MAIN_NOMCOM_TEMPLATE_PATH = '/nomcom/defaults/' QUESTIONNAIRE_TEMPLATE = 'position/questionnaire.txt' HEADER_QUESTIONNAIRE_TEMPLATE = 'position/header_questionnaire.txt' -REQUIREMENTS_TEMPLATE = 'position/requirements.txt' +REQUIREMENTS_TEMPLATE = 'position/requirements' HOME_TEMPLATE = 'home.rst' INEXISTENT_PERSON_TEMPLATE = 'email/inexistent_person.txt' NOMINEE_EMAIL_TEMPLATE = 'email/new_nominee.txt' @@ -53,7 +53,7 @@ def get_nomcom_by_year(year): from ietf.nomcom.models import NomCom return get_object_or_404(NomCom, group__acronym__icontains=year, - group__state__slug='active') + ) def get_year_by_nomcom(nomcom): @@ -271,42 +271,22 @@ def send_reminder_to_nominees(nominees,type): return addrs -def get_or_create_nominee(nomcom, candidate_name, candidate_email, position, author): +def make_nomineeposition(nomcom, candidate, position, author): from ietf.nomcom.models import Nominee, NomineePosition nomcom_template_path = '/nomcom/%s/' % nomcom.group.acronym - # Create person and email if candidate email does't exist and send email - email, created_email = Email.objects.get_or_create(address=candidate_email) - if created_email: - person = Person.objects.create(name=candidate_name, - ascii=unaccent.asciify(candidate_name), - address=candidate_email) - email.person = person - email.save() - # Add the nomination for a particular position - nominee, created = Nominee.objects.get_or_create(email=email, nomcom=nomcom) + nominee, created = Nominee.objects.get_or_create(person=candidate,email=candidate.email(), nomcom=nomcom) while nominee.duplicated: nominee = nominee.duplicated nominee_position, nominee_position_created = NomineePosition.objects.get_or_create(position=position, nominee=nominee) - if created_email: - # send email to secretariat and nomcomchair to warn about the new person - subject = 'New person is created' - from_email = settings.NOMCOM_FROM_EMAIL - (to_email, cc) = gather_address_lists('nomination_created_person',nomcom=nomcom) - context = {'email': email.address, - 'fullname': email.person.name, - 'person_id': email.person.id} - path = nomcom_template_path + INEXISTENT_PERSON_TEMPLATE - send_mail(None, to_email, from_email, subject, path, context, cc=cc) - if nominee_position_created: # send email to nominee subject = 'IETF Nomination Information' from_email = settings.NOMCOM_FROM_EMAIL - (to_email, cc) = gather_address_lists('nomination_new_nominee',nominee=email.address) + (to_email, cc) = gather_address_lists('nomination_new_nominee',nominee=nominee.email.address) domain = Site.objects.get_current().domain today = datetime.date.today().strftime('%Y%m%d') hash = get_hash_nominee_position(today, nominee_position.id) @@ -325,7 +305,7 @@ def get_or_create_nominee(nomcom, candidate_name, candidate_email, position, aut today, hash)) - context = {'nominee': email.person.name, + context = {'nominee': nominee.person.name, 'position': position.name, 'domain': domain, 'accept_url': accept_url, @@ -338,8 +318,8 @@ def get_or_create_nominee(nomcom, candidate_name, candidate_email, position, aut if nomcom.send_questionnaire: subject = '%s Questionnaire' % position from_email = settings.NOMCOM_FROM_EMAIL - (to_email, cc) = gather_address_lists('nomcom_questionnaire',nominee=email.address) - context = {'nominee': email.person.name, + (to_email, cc) = gather_address_lists('nomcom_questionnaire',nominee=nominee.email.address) + context = {'nominee': nominee.person.name, 'position': position.name} path = '%s%d/%s' % (nomcom_template_path, position.id, HEADER_QUESTIONNAIRE_TEMPLATE) @@ -353,8 +333,8 @@ def get_or_create_nominee(nomcom, candidate_name, candidate_email, position, aut subject = 'Nomination Information' from_email = settings.NOMCOM_FROM_EMAIL (to_email, cc) = gather_address_lists('nomination_received',nomcom=nomcom) - context = {'nominee': email.person.name, - 'nominee_email': email.address, + context = {'nominee': nominee.person.name, + 'nominee_email': nominee.email.address, 'position': position.name} if author: @@ -369,6 +349,28 @@ def get_or_create_nominee(nomcom, candidate_name, candidate_email, position, aut return nominee +def make_nomineeposition_for_newperson(nomcom, candidate_name, candidate_email, position, author): + + # This is expected to fail if called with an existing email address + email = Email.objects.create(address=candidate_email) + person = Person.objects.create(name=candidate_name, + ascii=unaccent.asciify(candidate_name), + address=candidate_email) + email.person = person + email.save() + + # send email to secretariat and nomcomchair to warn about the new person + subject = 'New person is created' + from_email = settings.NOMCOM_FROM_EMAIL + (to_email, cc) = gather_address_lists('nomination_created_person',nomcom=nomcom) + context = {'email': email.address, + 'fullname': email.person.name, + 'person_id': email.person.id} + nomcom_template_path = '/nomcom/%s/' % nomcom.group.acronym + path = nomcom_template_path + INEXISTENT_PERSON_TEMPLATE + send_mail(None, to_email, from_email, subject, path, context, cc=cc) + + return make_nomineeposition(nomcom, email.person, position, author) def getheader(header_text, default="ascii"): """Decode the specified header""" diff --git a/ietf/nomcom/views.py b/ietf/nomcom/views.py index 3515a91cc..6326f6f42 100644 --- a/ietf/nomcom/views.py +++ b/ietf/nomcom/views.py @@ -1,9 +1,10 @@ import datetime import re -from collections import OrderedDict +from collections import OrderedDict, Counter from django.conf import settings from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import AnonymousUser from django.contrib import messages from django.core.urlresolvers import reverse from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden @@ -15,31 +16,29 @@ from django.forms.models import modelformset_factory, inlineformset_factory from ietf.dbtemplate.models import DBTemplate -from ietf.dbtemplate.views import template_edit +from ietf.dbtemplate.views import template_edit, template_show from ietf.name.models import NomineePositionStateName, FeedbackTypeName from ietf.group.models import Group, GroupEvent from ietf.message.models import Message from ietf.nomcom.decorators import nomcom_private_key_required -from ietf.nomcom.forms import (NominateForm, FeedbackForm, QuestionnaireForm, +from ietf.nomcom.forms import (NominateForm, NominateNewPersonForm, FeedbackForm, QuestionnaireForm, MergeForm, NomComTemplateForm, PositionForm, PrivateKeyForm, EditNomcomForm, EditNomineeForm, PendingFeedbackForm, ReminderDatesForm, FullFeedbackFormSet, - FeedbackEmailForm) -from ietf.nomcom.models import Position, NomineePosition, Nominee, Feedback, NomCom, ReminderDates + FeedbackEmailForm, NominationResponseCommentForm) +from ietf.nomcom.models import Position, NomineePosition, Nominee, Feedback, NomCom, ReminderDates, FeedbackLastSeen from ietf.nomcom.utils import (get_nomcom_by_year, store_nomcom_private_key, get_hash_nominee_position, send_reminder_to_nominees, HOME_TEMPLATE, NOMINEE_ACCEPT_REMINDER_TEMPLATE,NOMINEE_QUESTIONNAIRE_REMINDER_TEMPLATE) from ietf.ietfauth.utils import role_required +import debug # pyflakes:ignore + def index(request): nomcom_list = Group.objects.filter(type__slug='nomcom').order_by('acronym') for nomcom in nomcom_list: - year = nomcom.acronym[6:] - try: - year = int(year) - except ValueError: - year = None + year = int(nomcom.acronym[6:]) nomcom.year = year nomcom.label = "%s/%s" % (year, year+1) if year in [ 2005, 2006, 2007, 2008, 2009, 2010 ]: @@ -107,11 +106,11 @@ def announcements(request): @role_required("Nomcom") def private_key(request, year): nomcom = get_nomcom_by_year(year) - message = None + if request.session.get('NOMCOM_PRIVATE_KEY_%s' % year, None): - message = ('warning', 'You already have a private decryption key set for this session.') + messages.warning(request, 'You already have a private decryption key set for this session.') else: - message = ('warning', "You don't have a private decryption key set for this session yet") + messages.warning(request, "You don't have a private decryption key set for this session yet") back_url = request.GET.get('back_to', reverse('nomcom_private_index', None, args=(year, ))) if request.method == 'POST': @@ -126,7 +125,6 @@ def private_key(request, year): 'year': year, 'back_url': back_url, 'form': form, - 'message': message, 'selected': 'private_key'}, RequestContext(request)) @@ -135,23 +133,25 @@ def private_index(request, year): nomcom = get_nomcom_by_year(year) all_nominee_positions = NomineePosition.objects.get_by_nomcom(nomcom).not_duplicated() is_chair = nomcom.group.has_role(request.user, "chair") - message = None if is_chair and request.method == 'POST': - action = request.POST.get('action') - nominations_to_modify = request.POST.getlist('selected') - if nominations_to_modify: - nominations = all_nominee_positions.filter(id__in=nominations_to_modify) - if action == "set_as_accepted": - nominations.update(state='accepted') - message = ('success', 'The selected nominations have been set as accepted') - elif action == "set_as_declined": - nominations.update(state='declined') - message = ('success', 'The selected nominations have been set as declined') - elif action == "set_as_pending": - nominations.update(state='pending') - message = ('success', 'The selected nominations have been set as pending') + if nomcom.group.state_id != 'active': + messages.warning(request, "This nomcom is not active. Request administrative assistance if Nominee state needs to change.") else: - message = ('warning', "Please, select some nominations to work with") + action = request.POST.get('action') + nominations_to_modify = request.POST.getlist('selected') + if nominations_to_modify: + nominations = all_nominee_positions.filter(id__in=nominations_to_modify) + if action == "set_as_accepted": + nominations.update(state='accepted') + messages.success(request,'The selected nominations have been set as accepted') + elif action == "set_as_declined": + nominations.update(state='declined') + messages.success(request,'The selected nominations have been set as declined') + elif action == "set_as_pending": + nominations.update(state='pending') + messages.success(request,'The selected nominations have been set as pending') + else: + messages.warning(request, "Please, select some nominations to work with") filters = {} questionnaire_state = "questionnaire" @@ -193,7 +193,7 @@ def private_index(request, year): 'selected_position': selected_position and int(selected_position) or None, 'selected': 'index', 'is_chair': is_chair, - 'message': message}, RequestContext(request)) + }, RequestContext(request)) @role_required("Nomcom Chair", "Nomcom Advisor") @@ -201,6 +201,16 @@ def send_reminder_mail(request, year, type): nomcom = get_nomcom_by_year(year) nomcom_template_path = '/nomcom/%s/' % nomcom.group.acronym + has_publickey = nomcom.public_key and True or False + if not has_publickey: + messages.warning(request, "This Nomcom does not yet have a public key.") + nomcom_ready = False + elif nomcom.group.state_id != 'active': + messages.warning(request, "This Nomcom is not active.") + nomcom_ready = False + else: + nomcom_ready = True + if type=='accept': interesting_state = 'pending' mail_path = nomcom_template_path + NOMINEE_ACCEPT_REMINDER_TEMPLATE @@ -228,19 +238,19 @@ def send_reminder_mail(request, year, type): mail_template = DBTemplate.objects.filter(group=nomcom.group, path=mail_path) mail_template = mail_template and mail_template[0] or None - message = None - if request.method == 'POST': + if request.method == 'POST' and nomcom_ready: selected_nominees = request.POST.getlist('selected') selected_nominees = nominees.filter(id__in=selected_nominees) if selected_nominees: addrs = send_reminder_to_nominees(selected_nominees,type) if addrs: - message = ('success', 'A copy of "%s" has been sent to %s'%(mail_template.title,", ".join(addrs))) + messages.success(request, 'A copy of "%s" has been sent to %s'%(mail_template.title,", ".join(addrs))) else: - message = ('warning', 'No messages were sent.') + messages.warning(request, 'No messages were sent.') else: - message = ('warning', "Please, select at least one nominee") + messages.warning(request, "Please, select at least one nominee") + return render_to_response('nomcom/send_reminder_mail.html', {'nomcom': nomcom, 'year': year, @@ -249,27 +259,33 @@ def send_reminder_mail(request, year, type): 'selected': selected_tab, 'reminder_description': reminder_description, 'state_description': state_description, - 'message': message}, RequestContext(request)) + 'is_chair_task' : True, + }, RequestContext(request)) @role_required("Nomcom Chair", "Nomcom Advisor") def private_merge(request, year): nomcom = get_nomcom_by_year(year) - message = None - if request.method == 'POST': - form = MergeForm(request.POST, nomcom=nomcom) - if form.is_valid(): - form.save() - message = ('success', 'The emails have been unified') + if nomcom.group.state_id != 'active': + messages.warning(request, "This Nomcom is not active.") + form = None else: - form = MergeForm(nomcom=nomcom) + if request.method == 'POST': + form = MergeForm(request.POST, nomcom=nomcom ) + if form.is_valid(): + form.save() + messages.success(request, 'A merge request has been sent to the secretariat.') + return redirect('nomcom_private_index',year=year) + else: + form = MergeForm(nomcom=nomcom) return render_to_response('nomcom/private_merge.html', {'nomcom': nomcom, 'year': year, 'form': form, - 'message': message, - 'selected': 'merge'}, RequestContext(request)) + 'selected': 'merge', + 'is_chair_task' : True, + }, RequestContext(request)) def requirements(request, year): @@ -294,15 +310,24 @@ def questionnaires(request, year): @login_required def public_nominate(request, year): - return nominate(request, year, True) + return nominate(request=request, year=year, public=True, newperson=False) @role_required("Nomcom") def private_nominate(request, year): - return nominate(request, year, False) + return nominate(request=request, year=year, public=False, newperson=False) + +@login_required +def public_nominate_newperson(request, year): + return nominate(request=request, year=year, public=True, newperson=True) -def nominate(request, year, public): +@role_required("Nomcom") +def private_nominate_newperson(request, year): + return nominate(request=request, year=year, public=False, newperson=True) + + +def nominate(request, year, public, newperson): nomcom = get_nomcom_by_year(year) has_publickey = nomcom.public_key and True or False if public: @@ -311,31 +336,43 @@ def nominate(request, year, public): template = 'nomcom/private_nominate.html' if not has_publickey: - message = ('warning', "This Nomcom is not yet accepting nominations") + messages.warning(request, "This Nomcom is not yet accepting nominations") return render_to_response(template, - {'message': message, - 'nomcom': nomcom, + {'nomcom': nomcom, + 'year': year, + 'selected': 'nominate'}, RequestContext(request)) + + if nomcom.group.state_id == 'conclude': + messages.warning(request, "Nominations to this Nomcom are closed.") + return render_to_response(template, + {'nomcom': nomcom, 'year': year, 'selected': 'nominate'}, RequestContext(request)) - message = None if request.method == 'POST': - form = NominateForm(data=request.POST, nomcom=nomcom, user=request.user, public=public) + if newperson: + form = NominateNewPersonForm(data=request.POST, nomcom=nomcom, user=request.user, public=public) + else: + form = NominateForm(data=request.POST, nomcom=nomcom, user=request.user, public=public) if form.is_valid(): form.save() - message = ('success', 'Your nomination has been registered. Thank you for the nomination.') - form = NominateForm(nomcom=nomcom, user=request.user, public=public) + messages.success(request, 'Your nomination has been registered. Thank you for the nomination.') + if newperson: + return redirect('nomcom_%s_nominate' % ('public' if public else 'private'), year=year) + else: + form = NominateForm(nomcom=nomcom, user=request.user, public=public) else: - form = NominateForm(nomcom=nomcom, user=request.user, public=public) + if newperson: + form = NominateNewPersonForm(nomcom=nomcom, user=request.user, public=public) + else: + form = NominateForm(nomcom=nomcom, user=request.user, public=public) return render_to_response(template, {'form': form, - 'message': message, 'nomcom': nomcom, 'year': year, 'selected': 'nominate'}, RequestContext(request)) - @login_required def public_feedback(request, year): return feedback(request, year, True) @@ -349,55 +386,61 @@ def private_feedback(request, year): def feedback(request, year, public): nomcom = get_nomcom_by_year(year) has_publickey = nomcom.public_key and True or False - submit_disabled = True nominee = None position = None - selected_nominee = request.GET.get('nominee') - selected_position = request.GET.get('position') - if selected_nominee and selected_position: - nominee = get_object_or_404(Nominee, id=selected_nominee) - position = get_object_or_404(Position, id=selected_position) - submit_disabled = False + if nomcom.group.state_id != 'conclude': + selected_nominee = request.GET.get('nominee') + selected_position = request.GET.get('position') + if selected_nominee and selected_position: + nominee = get_object_or_404(Nominee, id=selected_nominee) + position = get_object_or_404(Position, id=selected_position) positions = Position.objects.get_by_nomcom(nomcom=nomcom).opened() + user_comments = Feedback.objects.filter(nomcom=nomcom, + type='comment', + author__in=request.user.person.email_set.filter(active='True')) + counter = Counter(user_comments.values_list('positions','nominees')) + counts = dict() + for pos,nom in counter: + counts.setdefault(pos,dict())[nom] = counter[(pos,nom)] if public: base_template = "nomcom/nomcom_public_base.html" else: base_template = "nomcom/nomcom_private_base.html" if not has_publickey: - message = ('warning', "This Nomcom is not yet accepting comments") + messages.warning(request, "This Nomcom is not yet accepting comments") return render(request, 'nomcom/feedback.html', { - 'message': message, 'nomcom': nomcom, 'year': year, 'selected': 'feedback', + 'counts' : counts, 'base_template': base_template }) - message = None - if request.method == 'POST': + if nominee and position and request.method == 'POST': form = FeedbackForm(data=request.POST, nomcom=nomcom, user=request.user, public=public, position=position, nominee=nominee) if form.is_valid(): form.save() - message = ('success', 'Your feedback has been registered.') + messages.success(request, 'Your feedback has been registered.') + form = None + else: + if nominee and position: form = FeedbackForm(nomcom=nomcom, user=request.user, public=public, position=position, nominee=nominee) - else: - form = FeedbackForm(nomcom=nomcom, user=request.user, public=public, - position=position, nominee=nominee) + else: + form = None return render(request, 'nomcom/feedback.html', { 'form': form, - 'message': message, 'nomcom': nomcom, 'year': year, 'positions': positions, - 'submit_disabled': submit_disabled, 'selected': 'feedback', + 'counts': counts, 'base_template': base_template }) @@ -406,16 +449,24 @@ def feedback(request, year, public): def private_feedback_email(request, year): nomcom = get_nomcom_by_year(year) has_publickey = nomcom.public_key and True or False - message = None template = 'nomcom/private_feedback_email.html' if not has_publickey: - message = ('warning', "This Nomcom is not yet accepting feedback email") - return render_to_response(template, - {'message': message, - 'nomcom': nomcom, - 'year': year, - 'selected': 'feedback_email'}, RequestContext(request)) + messages.warning(request, "This Nomcom is not yet accepting feedback email.") + nomcom_ready = False + elif nomcom.group.state_id != 'active': + messages.warning(request, "This Nomcom is not active, and is not accepting feedback email.") + nomcom_ready = False + else: + nomcom_ready = True + + if not nomcom_ready: + return render_to_response(template, + {'nomcom': nomcom, + 'year': year, + 'selected': 'feedback_email', + 'is_chair_task' : True, + }, RequestContext(request)) form = FeedbackEmailForm(nomcom=nomcom) @@ -425,38 +476,44 @@ def private_feedback_email(request, year): if form.is_valid(): form.save() form = FeedbackEmailForm(nomcom=nomcom) - message = ('success', 'The feedback email has been registered.') + messages.success(request, 'The feedback email has been registered.') return render_to_response(template, {'form': form, - 'message': message, 'nomcom': nomcom, 'year': year, 'selected': 'feedback_email'}, RequestContext(request)) - @role_required("Nomcom Chair", "Nomcom Advisor") def private_questionnaire(request, year): nomcom = get_nomcom_by_year(year) has_publickey = nomcom.public_key and True or False - message = None questionnaire_response = None template = 'nomcom/private_questionnaire.html' if not has_publickey: - message = ('warning', "This Nomcom is not yet accepting questionnaires") - return render_to_response(template, - {'message': message, - 'nomcom': nomcom, - 'year': year, - 'selected': 'questionnaire'}, RequestContext(request)) + messages.warning(request, "This Nomcom is not yet accepting questionnaires.") + nomcom_ready = False + elif nomcom.group.state_id != 'active': + messages.warning(request, "This Nomcom is not active, and is not accepting questionnaires.") + nomcom_ready = False + else: + nomcom_ready = True + + if not nomcom_ready: + return render_to_response(template, + {'nomcom': nomcom, + 'year': year, + 'selected': 'questionnaire', + 'is_chair_task' : True, + }, RequestContext(request)) if request.method == 'POST': form = QuestionnaireForm(data=request.POST, nomcom=nomcom, user=request.user) if form.is_valid(): form.save() - message = ('success', 'The questionnaire response has been registered.') + messages.success(request, 'The questionnaire response has been registered.') questionnaire_response = form.cleaned_data['comments'] form = QuestionnaireForm(nomcom=nomcom, user=request.user) else: @@ -465,7 +522,6 @@ def private_questionnaire(request, year): return render_to_response(template, {'form': form, 'questionnaire_response': questionnaire_response, - 'message': message, 'nomcom': nomcom, 'year': year, 'selected': 'questionnaire'}, RequestContext(request)) @@ -478,35 +534,52 @@ def process_nomination_status(request, year, nominee_position_id, state, date, h expiration_days = getattr(settings, 'DAYS_TO_EXPIRE_NOMINATION_LINK', None) if expiration_days: request_date = datetime.date(int(date[:4]), int(date[4:6]), int(date[6:])) - if datetime.date.today() > (request_date + datetime.timedelta(days=settings.DAYS_TO_EXPIRE_REGISTRATION_LINK)): + if datetime.date.today() > (request_date + datetime.timedelta(days=settings.DAYS_TO_EXPIRE_NOMINATION_LINK)): return HttpResponseForbidden("Link expired") need_confirmation = True nomcom = get_nomcom_by_year(year) + if nomcom.group.state_id == 'conclude': + return HttpResponseForbidden("This nomcom is concluded.") nominee_position = get_object_or_404(NomineePosition, id=nominee_position_id) if nominee_position.state.slug != "pending": return HttpResponseForbidden("The nomination already was %s" % nominee_position.state) state = get_object_or_404(NomineePositionStateName, slug=state) - message = ('warning', - ("Click on 'Save' to set the state of your nomination to %s to %s (this"+ - "is not a final commitment - you can notify us later if you need to change this)") % - (nominee_position.position.name, state.name)) + messages.info(request, "Click on 'Save' to set the state of your nomination to %s to %s (this is not a final commitment - you can notify us later if you need to change this)." % (nominee_position.position.name, state.name)) if request.method == 'POST': - nominee_position.state = state - nominee_position.save() - need_confirmation = False - message = message = ('success', 'Your nomination on %s has been set as %s' % (nominee_position.position.name, - state.name)) - + form = NominationResponseCommentForm(request.POST) + if form.is_valid(): + nominee_position.state = state + nominee_position.save() + need_confirmation = False + if form.cleaned_data['comments']: + # This Feedback object is of type comment instead of nomina in order to not + # make answering "who nominated themselves" harder. + who = request.user + if isinstance(who,AnonymousUser): + who = None + f = Feedback.objects.create(nomcom = nomcom, + author = nominee_position.nominee.email, + subject = '%s nomination %s'%(nominee_position.nominee.name(),state), + comments = form.cleaned_data['comments'], + type_id = 'comment', + user = who, + ) + f.positions.add(nominee_position.position) + f.nominees.add(nominee_position.nominee) + + messages.success(request, 'Your nomination on %s has been set as %s' % (nominee_position.position.name, state.name)) + else: + form = NominationResponseCommentForm() return render_to_response('nomcom/process_nomination_status.html', - {'message': message, - 'nomcom': nomcom, + {'nomcom': nomcom, 'year': year, 'nominee_position': nominee_position, 'state': state, 'need_confirmation': need_confirmation, - 'selected': 'feedback'}, RequestContext(request)) + 'selected': 'feedback', + 'form': form }, RequestContext(request)) @role_required("Nomcom") @@ -521,10 +594,36 @@ def view_feedback(request, year): feedback_types.append(ft) else: independent_feedback_types.append(ft) - nominees_feedback = {} + nominees_feedback = [] + + def nominee_staterank(nominee): + states=nominee.nomineeposition_set.values_list('state_id',flat=True) + if 'accepted' in states: + return 0 + elif 'pending' in states: + return 1 + else: + return 2 + for nominee in nominees: - nominee_feedback = [(ft.name, nominee.feedback_set.by_type(ft.slug).count()) for ft in feedback_types] - nominees_feedback.update({nominee: nominee_feedback}) + nominee.staterank = nominee_staterank(nominee) + + sorted_nominees = sorted(nominees,key=lambda x:x.staterank) + + for nominee in sorted_nominees: + last_seen = FeedbackLastSeen.objects.filter(reviewer=request.user.person,nominee=nominee).first() + nominee_feedback = [] + for ft in feedback_types: + qs = nominee.feedback_set.by_type(ft.slug) + count = qs.count() + if not count: + newflag = False + elif not last_seen: + newflag = True + else: + newflag = qs.filter(time__gt=last_seen.time).exists() + nominee_feedback.append( (ft.name,count,newflag) ) + nominees_feedback.append( {'nominee':nominee, 'feedback':nominee_feedback} ) independent_feedback = [ft.feedback_set.get_by_nomcom(nomcom).count() for ft in independent_feedback_types] return render_to_response('nomcom/view_feedback.html', @@ -542,33 +641,17 @@ def view_feedback(request, year): @nomcom_private_key_required def view_feedback_pending(request, year): nomcom = get_nomcom_by_year(year) + if nomcom.group.state_id == 'conclude': + return HttpResponseForbidden("This nomcom is concluded.") extra_ids = None - message = None - for message in messages.get_messages(request): - message = ('success', message.message) FeedbackFormSet = modelformset_factory(Feedback, form=PendingFeedbackForm, extra=0) feedbacks = Feedback.objects.filter(type__isnull=True, nomcom=nomcom) - try: - default_type = FeedbackTypeName.objects.get(slug=settings.DEFAULT_FEEDBACK_TYPE) - except FeedbackTypeName.DoesNotExist: - default_type = None extra_step = False - if request.method == 'POST' and request.POST.get('move_to_default'): - formset = FeedbackFormSet(request.POST) - if formset.is_valid(): - for form in formset.forms: - form.set_nomcom(nomcom, request.user) - form.move_to_default() - formset = FeedbackFormSet(queryset=feedbacks) - for form in formset.forms: - form.set_nomcom(nomcom, request.user) - messages.success(request, 'Feedback saved') - return redirect('nomcom_view_feedback_pending', year=year) - elif request.method == 'POST' and request.POST.get('end'): + if request.method == 'POST' and request.POST.get('end'): extra_ids = request.POST.get('extra_ids', None) extra_step = True formset = FullFeedbackFormSet(request.POST) @@ -623,7 +706,7 @@ def view_feedback_pending(request, year): for form in formset.forms: form.set_nomcom(nomcom, request.user, extra) if moved: - message = ('success', '%s messages classified. You must enter more information for the following feedback.' % moved) + messages.success(request, '%s messages classified. You must enter more information for the following feedback.' % moved) else: messages.success(request, 'Feedback saved') return redirect('nomcom_view_feedback_pending', year=year) @@ -644,13 +727,13 @@ def view_feedback_pending(request, year): {'year': year, 'selected': 'feedback_pending', 'formset': formset, - 'message': message, 'extra_step': extra_step, - 'default_type': default_type, 'type_dict': type_dict, 'extra_ids': extra_ids, 'types': FeedbackTypeName.objects.all().order_by('pk'), - 'nomcom': nomcom}, RequestContext(request)) + 'nomcom': nomcom, + 'is_chair_task' : True, + }, RequestContext(request)) @role_required("Nomcom") @@ -676,11 +759,19 @@ def view_feedback_nominee(request, year, nominee_id): nominee = get_object_or_404(Nominee, id=nominee_id) feedback_types = FeedbackTypeName.objects.filter(slug__in=settings.NOMINEE_FEEDBACK_TYPES) + last_seen = FeedbackLastSeen.objects.filter(reviewer=request.user.person,nominee=nominee).first() + last_seen_time = (last_seen and last_seen.time) or datetime.datetime(year=1,month=1,day=1) + if last_seen: + last_seen.save() + else: + FeedbackLastSeen.objects.create(reviewer=request.user.person,nominee=nominee) + return render_to_response('nomcom/view_feedback_nominee.html', {'year': year, 'selected': 'view_feedback', 'nominee': nominee, 'feedback_types': feedback_types, + 'last_seen_time' : last_seen_time, 'nomcom': nomcom}, RequestContext(request)) @@ -688,14 +779,14 @@ def view_feedback_nominee(request, year, nominee_id): def edit_nominee(request, year, nominee_id): nomcom = get_nomcom_by_year(year) nominee = get_object_or_404(Nominee, id=nominee_id) - message = None if request.method == 'POST': form = EditNomineeForm(request.POST, instance=nominee) if form.is_valid(): form.save() - message = ('success', 'The nominee has been changed') + messages.success(request, 'The nomination address for %s has been changed to %s'%(nominee.name(),nominee.email.address)) + return redirect('nomcom_private_index', year=year) else: form = EditNomineeForm(instance=nominee) @@ -704,8 +795,9 @@ def edit_nominee(request, year, nominee_id): 'selected': 'index', 'nominee': nominee, 'form': form, - 'message': message, - 'nomcom': nomcom}, RequestContext(request)) + 'nomcom': nomcom, + 'is_chair_task' : True, + }, RequestContext(request)) @role_required("Nomcom Chair", "Nomcom Advisor") @@ -713,14 +805,18 @@ def edit_nomcom(request, year): nomcom = get_nomcom_by_year(year) if nomcom.public_key: - message = ('warning', 'Previous data will remain encrypted with the old key') + messages.warning(request, 'Previous data will remain encrypted with the old key') else: - message = ('warning', 'The nomcom has not a public key yet') + messages.warning(request, 'This Nomcom does not yet have a public key') ReminderDateInlineFormSet = inlineformset_factory(parent_model=NomCom, model=ReminderDates, form=ReminderDatesForm) if request.method == 'POST': + + if nomcom.group.state_id=='conclude': + return HttpResponseForbidden('This nomcom is closed.') + formset = ReminderDateInlineFormSet(request.POST, instance=nomcom) form = EditNomcomForm(request.POST, request.FILES, @@ -729,7 +825,7 @@ def edit_nomcom(request, year): form.save() formset.save() formset = ReminderDateInlineFormSet(instance=nomcom) - message = ('success', 'The nomcom has been changed') + messages.success(request, 'The nomcom has been changed') else: formset = ReminderDateInlineFormSet(instance=nomcom) form = EditNomcomForm(instance=nomcom) @@ -738,26 +834,12 @@ def edit_nomcom(request, year): {'form': form, 'formset': formset, 'nomcom': nomcom, - 'message': message, 'year': year, - 'selected': 'edit_nomcom'}, RequestContext(request)) + 'selected': 'edit_nomcom', + 'is_chair_task' : True, + }, RequestContext(request)) -@role_required("Nomcom Chair", "Nomcom Advisor") -def delete_nomcom(request, year): - nomcom = get_nomcom_by_year(year) - - if request.method == 'POST': - nomcom.delete() - messages.success(request, "Deleted NomCom data") - return redirect('nomcom_deleted') - - return render(request, 'nomcom/delete_nomcom.html', { - 'year': year, - 'selected': 'edit_nomcom', - 'nomcom': nomcom, - }) - @role_required("Nomcom Chair", "Nomcom Advisor") def list_templates(request, year): @@ -770,7 +852,9 @@ def list_templates(request, year): 'positions': positions, 'year': year, 'selected': 'edit_templates', - 'nomcom': nomcom}, RequestContext(request)) + 'nomcom': nomcom, + 'is_chair_task' : True, + }, RequestContext(request)) @role_required("Nomcom Chair", "Nomcom Advisor") @@ -778,29 +862,44 @@ def edit_template(request, year, template_id): nomcom = get_nomcom_by_year(year) return_url = request.META.get('HTTP_REFERER', None) - return template_edit(request, nomcom.group.acronym, template_id, - base_template='nomcom/edit_template.html', - formclass=NomComTemplateForm, - extra_context={'year': year, - 'return_url': return_url, - 'nomcom': nomcom}) + if nomcom.group.state_id=='conclude': + return template_show(request, nomcom.group.acronym, template_id, + base_template='nomcom/show_template.html', + extra_context={'year': year, + 'return_url': return_url, + 'nomcom': nomcom, + 'is_chair_task' : True, + }) + else: + return template_edit(request, nomcom.group.acronym, template_id, + base_template='nomcom/edit_template.html', + formclass=NomComTemplateForm, + extra_context={'year': year, + 'return_url': return_url, + 'nomcom': nomcom, + 'is_chair_task' : True, + }) @role_required("Nomcom Chair", "Nomcom Advisor") def list_positions(request, year): nomcom = get_nomcom_by_year(year) - positions = nomcom.position_set.all() + positions = nomcom.position_set.order_by('-is_open') return render_to_response('nomcom/list_positions.html', {'positions': positions, 'year': year, 'selected': 'edit_positions', - 'nomcom': nomcom}, RequestContext(request)) + 'nomcom': nomcom, + 'is_chair_task' : True, + }, RequestContext(request)) @role_required("Nomcom Chair", "Nomcom Advisor") def remove_position(request, year, position_id): nomcom = get_nomcom_by_year(year) + if nomcom.group.state_id=='conclude': + return HttpResponseForbidden('This nomcom is closed.') try: position = nomcom.position_set.get(id=position_id) except Position.DoesNotExist: @@ -812,12 +911,18 @@ def remove_position(request, year, position_id): return render_to_response('nomcom/remove_position.html', {'year': year, 'position': position, - 'nomcom': nomcom}, RequestContext(request)) + 'nomcom': nomcom, + 'is_chair_task' : True, + }, RequestContext(request)) @role_required("Nomcom Chair", "Nomcom Advisor") def edit_position(request, year, position_id=None): nomcom = get_nomcom_by_year(year) + + if nomcom.group.state_id=='conclude': + return HttpResponseForbidden('This nomcom is closed.') + if position_id: try: position = nomcom.position_set.get(id=position_id) @@ -838,4 +943,10 @@ def edit_position(request, year, position_id=None): {'form': form, 'position': position, 'year': year, - 'nomcom': nomcom}, RequestContext(request)) + 'nomcom': nomcom, + 'is_chair_task' : True, + }, RequestContext(request)) + +@role_required("Nomcom Chair", "Nomcom Advisor") +def configuration_help(request, year): + return render(request,'nomcom/chair_help.html',{'year':year}) diff --git a/ietf/person/factories.py b/ietf/person/factories.py new file mode 100644 index 000000000..2cc7e006e --- /dev/null +++ b/ietf/person/factories.py @@ -0,0 +1,58 @@ +import factory +import faker + +from unidecode import unidecode + +from django.contrib.auth.models import User +from ietf.person.models import Person, Alias, Email + +fake = faker.Factory.create() + +class UserFactory(factory.DjangoModelFactory): + class Meta: + model = User + django_get_or_create = ('username',) + + first_name = factory.Faker('first_name') + last_name = factory.Faker('last_name') + email = factory.LazyAttribute(lambda u: '%s.%s@%s'%(u.first_name,u.last_name,fake.domain_name())) + username = factory.LazyAttribute(lambda u: u.email) + + @factory.post_generation + def set_password(self, create, extracted, **kwargs): + self.set_password( '%s+password' % self.username ) + +class PersonFactory(factory.DjangoModelFactory): + class Meta: + model = Person + + user = factory.SubFactory(UserFactory) + name = factory.LazyAttribute(lambda p: '%s %s'%(p.user.first_name,p.user.last_name)) + ascii = factory.LazyAttribute(lambda p: unidecode(p.name)) + + @factory.post_generation + def default_aliases(self, create, extracted, **kwargs): + make_alias = getattr(AliasFactory, 'create' if create else 'build') + make_alias(person=self,name=self.name) + make_alias(person=self,name=self.ascii) + + @factory.post_generation + def default_emails(self, create, extracted, **kwargs): + make_email = getattr(EmailFactory, 'create' if create else 'build') + make_email(person=self,address=self.user.email) + +class AliasFactory(factory.DjangoModelFactory): + class Meta: + model = Alias + django_get_or_create = ('name',) + + name = factory.Faker('name') + +class EmailFactory(factory.DjangoModelFactory): + class Meta: + model = Email + django_get_or_create = ('address',) + + address = '%s.%s@%s' % (fake.first_name(),fake.last_name(),fake.domain_name()) + active = True + primary = False diff --git a/ietf/person/fields.py b/ietf/person/fields.py index 34e16b901..177da9cb7 100644 --- a/ietf/person/fields.py +++ b/ietf/person/fields.py @@ -1,5 +1,7 @@ import json +from collections import Counter + from django.utils.html import escape from django import forms from django.core.urlresolvers import reverse as urlreverse @@ -12,7 +14,19 @@ def select2_id_name_json(objs): def format_email(e): return escape(u"%s <%s>" % (e.person.name, e.address)) def format_person(p): - return escape(p.name) + if p.name_count > 1: + return escape('%s (%s)' % (p.name,p.email().address)) + else: + return escape(p.name) + + if objs and isinstance(objs[0], Email): + formatter = format_email + else: + formatter = format_person + c = Counter([p.name for p in objs]) + for p in objs: + p.name_count = c[p.name] + formatter = format_email if objs and isinstance(objs[0], Email) else format_person diff --git a/ietf/person/models.py b/ietf/person/models.py index e47276da0..db4f0f679 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -59,10 +59,13 @@ class PersonInfo(models.Model): if e: return e[0] return None - def email_address(self): + def email(self): e = self.email_set.filter(primary=True).first() if not e: e = self.email_set.filter(active=True).order_by("-time").first() + return e + def email_address(self): + e = self.email() if e: return e.address else: diff --git a/ietf/person/utils.py b/ietf/person/utils.py new file mode 100755 index 000000000..2229cf962 --- /dev/null +++ b/ietf/person/utils.py @@ -0,0 +1,88 @@ +import pprint + +from django.contrib import admin +from django.contrib.auth.models import User +from ietf.person.models import Person + +def merge_persons(source,target,stream): + + # merge emails + for email in source.email_set.all(): + print >>stream, "Merging email: {}".format(email.address) + email.person = target + email.save() + + # merge aliases + target_aliases = [ a.name for a in target.alias_set.all() ] + for alias in source.alias_set.all(): + if alias.name in target_aliases: + alias.delete() + else: + print >>stream,"Merging alias: {}".format(alias.name) + alias.person = target + alias.save() + + # merge DocEvents + for docevent in source.docevent_set.all(): + docevent.by = target + docevent.save() + + # merge SubmissionEvents + for subevent in source.submissionevent_set.all(): + subevent.by = target + subevent.save() + + # merge Messages + for message in source.message_set.all(): + message.by = target + message.save() + + # merge Constraints + for constraint in source.constraint_set.all(): + constraint.person = target + constraint.save() + + # merge Roles + for role in source.role_set.all(): + role.person = target + role.save() + + # merge Nominees + for nominee in source.nominee_set.all(): + target_nominee = target.nominee_set.get(nomcom=nominee.nomcom) + if not target_nominee: + target_nominee = target.nominee_set.create(nomcom=nominee.nomcom, email=target.email()) + nominee.nomination_set.all().update(nominee=target_nominee) + for fb in nominee.feedback_set.all(): + fb.nominees.remove(nominee) + fb.nominees.add(target_nominee) + for np in nominee.nomineeposition_set.all(): + existing_target_np = target_nominee.nomineeposition_set.filter(position=np.position).first() + if existing_target_np: + if existing_target_np.state.slug=='pending': + existing_target_np.state = np.state + existing_target_np.save() + np.delete() + else: + np.nominee=target_nominee + np.save() + nominee.delete() + + # check for any remaining relationships and delete if none + objs = [source] + opts = Person._meta + user = User.objects.filter(is_superuser=True).first() + admin_site = admin.site + using = 'default' + + deletable_objects, perms_needed, protected = admin.utils.get_deleted_objects( + objs, opts, user, admin_site, using) + + if len(deletable_objects) > 1: + print >>stream, "Not Deleting Person: {}({})".format(source.ascii,source.pk) + print >>stream, "Related objects remain:" + pprint.pprint(deletable_objects[1],stream=stream) + + else: + print >>stream, "Deleting Person: {}({})".format(source.ascii,source.pk) + source.delete() diff --git a/ietf/settings.py b/ietf/settings.py index 40aa1d207..f4ea228c3 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -455,7 +455,6 @@ NOMCOM_PUBLIC_KEYS_DIR = '/a/www/nomcom/public_keys/' NOMCOM_FROM_EMAIL = 'nomcom-chair@ietf.org' OPENSSL_COMMAND = '/usr/bin/openssl' DAYS_TO_EXPIRE_NOMINATION_LINK = '' -DEFAULT_FEEDBACK_TYPE = 'offtopic' NOMINEE_FEEDBACK_TYPES = ['comment', 'questio', 'nomina'] # ID Submission Tool settings diff --git a/ietf/settings_sqlitetest.py b/ietf/settings_sqlitetest.py index 82aa8b2be..3a2f2587a 100644 --- a/ietf/settings_sqlitetest.py +++ b/ietf/settings_sqlitetest.py @@ -5,6 +5,7 @@ # ./manage.py test --settings=settings_sqlitetest doc.ChangeStateTestCase # +import os from settings import * # pyflakes:ignore # Workaround to avoid spending minutes stepping through the migrations in @@ -38,4 +39,5 @@ DATABASES = { if TEST_CODE_COVERAGE_CHECKER and not TEST_CODE_COVERAGE_CHECKER._started: TEST_CODE_COVERAGE_CHECKER.start() - + +NOMCOM_PUBLIC_KEYS_DIR=os.path.abspath("tmp-nomcom-public-keys-dir") diff --git a/ietf/submit/tests.py b/ietf/submit/tests.py index 69f4de312..520b77a53 100644 --- a/ietf/submit/tests.py +++ b/ietf/submit/tests.py @@ -324,7 +324,7 @@ class SubmitTests(TestCase): self.assertTrue((u"I-D Action: %s" % name) in outbox[-3]["Subject"]) self.assertTrue((u"I-D Action: %s" % name) in draft.message_set.order_by("-time")[0].subject) self.assertTrue("Author Name" in unicode(outbox[-3])) - self.assertTrue("ietf-announce@" in outbox[-3]['To']) + self.assertTrue("i-d-announce@" in outbox[-3]['To']) self.assertTrue("New Version Notification" in outbox[-2]["Subject"]) self.assertTrue(name in unicode(outbox[-2])) self.assertTrue("mars" in unicode(outbox[-2])) diff --git a/ietf/templates/base.html b/ietf/templates/base.html index 12bf3097b..37122b359 100644 --- a/ietf/templates/base.html +++ b/ietf/templates/base.html @@ -1,6 +1,7 @@ {% load ietf_filters staticfiles %} {# Copyright The IETF Trust 2015, All Rights Reserved #} {% load origin %}{% origin %} +{% load bootstrap3 %} @@ -83,15 +84,7 @@ {% endwith %}
- {% if messages %} -
-
- {% for message in messages %} -

{{ message }}

- {% endfor %} -
-
- {% endif %} + {% bootstrap_messages %} {% if request.COOKIES.left_menu != "off" and not hide_menu %} {# ugly hack for the more or less unported meeting agenda edit pages #}
diff --git a/ietf/templates/nomcom/chair_help.html b/ietf/templates/nomcom/chair_help.html new file mode 100644 index 000000000..72845a84f --- /dev/null +++ b/ietf/templates/nomcom/chair_help.html @@ -0,0 +1,98 @@ +{% extends "nomcom/nomcom_private_base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %} + +{% load bootstrap3 %} +{% load ietf_filters %} + +{% block bodyAttrs %}data-spy="scroll" data-target="#nav-instructions"{% endblock %} + +{% block subtitle %} - Configuration Help {% endblock %} + +{% block nomcom_content %} + {% origin %} + + + +
+

Help for Configuring a New NomCom

+ + + + +

Generate a keypair for the nomcom

+ +

The Datatracker uses a public/private keypair to encrypt any feedback entered by the community +before storing it in the database. Only persons with the private key can view this feedback. +The private key is provided by using a datatracker page to store a blowfish-encrypted cookie in a browser session. +The blowfish-encrypted private key is sent to the server and used to decrypt feedback. The private key is never +stored on the server, but if the server is compromised, it would be possible for an attacker to grab the private key +by modifying the datatracker code. The NomCom chair generates the keypair for each NomCom and manages its secure +distribution. +

+ +

To generate the keypair: +

    +
  1. +Create a config file for openssl, named nomcom-config.cnf, with the following contents: +
    [ req ]
    +distinguished_name = req_distinguished_name
    +string_mask        = utf8only
    +x509_extensions    = ss_v3_ca
    +
    +[ req_distinguished_name ]
    +commonName           = Common Name (e.g. NomComYY)
    +commonName_default   = NomCom{{year|slice:"2:"}}
    +
    +[ ss_v3_ca ]
    +
    +subjectKeyIdentifier = hash
    +keyUsage = critical, digitalSignature, keyEncipherment, dataEncipherment
    +basicConstraints = critical, CA:true
    +subjectAltName = email:nomcom{{year|slice:"2:"}}@ietf.org
    +extendedKeyUsage= emailProtection
    +
  2. +
  3. Generate a private key and corresponding certificate: +
    openssl req -config nomcom-config.cnf -x509 -new -newkey rsa:2048 -sha256 -days 730 -nodes -keyout privateKey-nomcom{{year|slice:"2:"}}.pem -out nomcom{{year|slice:"2:"}}.cert
    +(Just press Enter when presented with "Common Name (e.g. NomComYY) [NomCom15]:") +
  4. +
+

+You will upload the certificate to the datatracker (and make it available to people wishing to send mail) in the steps below. +

+

Securely distribute privateKey-nomcom{{year|slice:"2:"}} to your NomCom advisor(s), liaisons, and members, as they become known.

+ + +

Configure the Datatracker NomCom

+ +

Sign into the datatracker and go to the NomCom Configuration Page.

+

Use the Browse button to select the public nomcom{{year|slice:"2:"}}.cert file created above.

+

Enter any special instructions you want to appear on the nomination entry form in the "Help text for nomination form" box. These will appear on the form immediately below the field labeled "Candidate's qualifications for the position".

+

Choose whether to have the datatracker send questionnares, and whether to automatically remind people to accept nominations and return questionnaires, according to the instructions on the form.

+

Press the save button.

+

You can return to this page and change your mind on any of the settings, even towards the end of your nomcom cycle. However, be wary of uploading a new public key once one feedback has been received. That step should only be taken in the case of a compromised keypair. Old feedback will remain encrypted with the old key, and will not be accessible through the datatracker.

+ +

Configure the Positions to be filled

+

Add the positions this nomcom needs to fill.

+

Only create one Position for those roles having multiple seats to fill, +such as the IAB, or the IESG areas where multiple ADs in that area are at the end of their term.

+ +

Note the "Is open" checkbox. When this is set to True, the Position will appear on the Nomination and Feedback pages. You will set this to False when you do not want any more feedback (that is, the position is filled, or otherwise closed for some reason). It is a good idea to start with the "Is open" value set to False. After you edit the templates for the position and are ready for the community to provide nominations and feedback, set the "Is open" value to True.

+

You might need to close some positions and open others as your nomcom progresses. For example, the 2014 Nomcom was called back after it had finished work on its usual selections to fill a IAOC position that had been vacated mid-term. Before making the call for nominations and feedback for this additional IAOC position, the chair would mark the already filled positions as not open, leaving only the new IAOC position open for consideration. At that point, only that IAOC position would be available on the Nomination and Feedback pages.

+ +

Customize the web-form and email templates

+ +

Edit each of the templates at {% url 'nomcom_list_templates' year %}. The "Home page of group" template is where to put information about the current nomcom members and policies. It is also a good place to list incumbents in positions, and information about whether the incumbents will stand again. See the home page of past nomcoms for examples.

+ +

Test the results

+

Before advertising that your nomcom pages are ready for the community to use, test your configuration. Create a dummy nominee for at least one position, and give it some feedback. You will be able to move this out of the way later. Once you've marked positions as open, ask your nomcom members to look over the expertise and questionnaires tab (which show rendered view of each of the templates for each position) to ensure they contain what you want the community to see. Please don't assume that everything is all right without looking. It's a good idea to give the secretariat and the tools team a heads up a few (preferably 3 to 5) days notice before announcing that your pages are ready for community use. +

+{% endblock %} + diff --git a/ietf/templates/nomcom/deleted.html b/ietf/templates/nomcom/deleted.html deleted file mode 100644 index eacc2a69c..000000000 --- a/ietf/templates/nomcom/deleted.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %} - -{% block title %}NomCom deleted{% endblock %} - -{% block content %} - {% origin %} -

NomCom deleted

- -

All data about the NomCom has been removed.

- -{% endblock %} diff --git a/ietf/templates/nomcom/edit_members.html b/ietf/templates/nomcom/edit_members.html index d80aa017e..5b7f3a38b 100644 --- a/ietf/templates/nomcom/edit_members.html +++ b/ietf/templates/nomcom/edit_members.html @@ -10,8 +10,6 @@ {% origin %}

Edit members

- {% bootstrap_messages %} -
{% csrf_token %} {% bootstrap_form form %} diff --git a/ietf/templates/nomcom/edit_nomcom.html b/ietf/templates/nomcom/edit_nomcom.html index d24ec445b..69414e824 100644 --- a/ietf/templates/nomcom/edit_nomcom.html +++ b/ietf/templates/nomcom/edit_nomcom.html @@ -14,12 +14,6 @@ {% origin %}

Settings

- {% if message %} -
{{ message.1 }}
- {% endif %} - - {% bootstrap_messages %} - {% csrf_token %} {% bootstrap_form form %} @@ -37,12 +31,6 @@ {% endbuttons %}
-

Delete Nomcom

- -

- Delete NomCom -

- {% endblock %} {% block js %} diff --git a/ietf/templates/nomcom/edit_nominee.html b/ietf/templates/nomcom/edit_nominee.html index ba566ede3..cf8a954ad 100644 --- a/ietf/templates/nomcom/edit_nominee.html +++ b/ietf/templates/nomcom/edit_nominee.html @@ -8,14 +8,9 @@ {% block nomcom_content %} {% origin %} - {% bootstrap_messages %}

Edit email
{{ nominee }}

- {% if message %} -

{{ message.1 }}

- {% endif %} -
{% csrf_token %} {% bootstrap_form form %} diff --git a/ietf/templates/nomcom/edit_position.html b/ietf/templates/nomcom/edit_position.html index 4799981fa..d47d3c68b 100644 --- a/ietf/templates/nomcom/edit_position.html +++ b/ietf/templates/nomcom/edit_position.html @@ -15,8 +15,6 @@ {% origin %}

{% if position %}Edit{% else %}Add{% endif %} position

- {% bootstrap_messages %} - {% csrf_token %} {% bootstrap_form form %} diff --git a/ietf/templates/nomcom/edit_template.html b/ietf/templates/nomcom/edit_template.html index 2607be40c..827a105bb 100644 --- a/ietf/templates/nomcom/edit_template.html +++ b/ietf/templates/nomcom/edit_template.html @@ -31,7 +31,7 @@ {% endif %} - + {% csrf_token %} {% bootstrap_form form %} diff --git a/ietf/templates/nomcom/feedback.html b/ietf/templates/nomcom/feedback.html index b6e9bc19a..9f2aafd87 100644 --- a/ietf/templates/nomcom/feedback.html +++ b/ietf/templates/nomcom/feedback.html @@ -5,24 +5,30 @@ {% load bootstrap3 %} {% load nomcom_tags %} +{% block morecss %} +.btn-group-vertical .btn { + text-align: left; +} +.btn-group-vertical .btn .badge { + float:right; margin-top: -1.3em; +} +{% endblock %} + {% block subtitle %} - Feedback{% endblock %} {% block nomcom_content %} {% origin %} -

- First select a nominee from the list of nominees to provide input about that nominee. - This will fill in the non-editable fields in the form. +

+ {% if nomcom.group.state_id == 'conclude' %} + Feedback to this nomcom is closed. + {% else %} + Select a nominee from the list of nominees to the right to obtain a new feedback form. + {% endif %}

- {% if message %} -

{{ message.1 }}

- {% endif %} - - {% bootstrap_messages %} - {% if nomcom|has_publickey %}
-
+

Nominees

{% for p in positions %} @@ -30,9 +36,14 @@

{{ p.name }}

@@ -42,22 +53,30 @@

An number after a name indicates that you have given comments on this nominee - earlier. If you position the mouse pointer over - it, you should see how many comments - exist from you for this nominee. + earlier. Position the mouse pointer over + the badge, for more information about this + nominee.

-

Provide feedback

+ {% if form %} +

Provide feedback + {% if form.position %} + about {{form.nominee.email.person.name}} ({{form.nominee.email.address}}) for the {{form.position.name}} position.

+ {% endif %} +

+

This feedback will only be available to NomCom {{year}}. + You may have the feedback mailed back to you by selecting the option below.

- - {% csrf_token %} - {% bootstrap_form form %} - {% buttons %} - - {% endbuttons %} + + {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + {% endbuttons %} + {% endif %}
{% endif %} diff --git a/ietf/templates/nomcom/list_positions.html b/ietf/templates/nomcom/list_positions.html index 90789d7d4..a53a16c60 100644 --- a/ietf/templates/nomcom/list_positions.html +++ b/ietf/templates/nomcom/list_positions.html @@ -8,31 +8,38 @@ {% origin %}

Positions in {{ nomcom.group }}

- Add new position + {% if nomcom.group.state_id == 'active' %} + Add new position +

+ {% endif %} {% if positions %} - {% for position in positions %} -

{{ position.name }}

+ {% regroup positions by is_open as posgroups %} + {% for group in posgroups %} +
+

{{ group.grouper| yesno:"Open Positions,Closed Positions"}}

+
+ {% for position in group.list %} +

{{ position.name }}

-
Description
-
{{ position.description }}
-
Incumbent
-
{% if position.incumbent %}{{ position.incumbent.person }} <{{ position.incumbent.address }}>{% else %}None{% endif %}
-
Is open
-
{{ position.is_open }}
Templates
{% for template in position.get_templates %} {{ template }}
{% endfor %}
+ {% if nomcom.group.state_id == 'active' %}
Actions
Edit Remove
+ {% endif %}
{% endfor %} +
+
+ {% endfor %} {% else %}

There are no positions defined.

{% endif %} diff --git a/ietf/templates/nomcom/merge_request.txt b/ietf/templates/nomcom/merge_request.txt new file mode 100644 index 000000000..9bdc9db31 --- /dev/null +++ b/ietf/templates/nomcom/merge_request.txt @@ -0,0 +1,11 @@ +The following Person records have been identified by the NomCom chair as potential duplicates. + +The following records:{% for p in duplicate_persons %} +{{p.name}} ({{p.id}}) [{{p.email_set.all|join:", "}}]{% endfor %} + +appear to be duplicates of this person (which should be kept) +{{primary_person.name}} ({{primary_person.id}}) [{{primary_person.email_set.all|join:", "}}] + +Please verify that these are indeed duplicates, and if so, merge them. + +Thanks in advance. diff --git a/ietf/templates/nomcom/nomcom_private_base.html b/ietf/templates/nomcom/nomcom_private_base.html index 46d2ba59d..78fe8256a 100644 --- a/ietf/templates/nomcom/nomcom_private_base.html +++ b/ietf/templates/nomcom/nomcom_private_base.html @@ -9,7 +9,7 @@ {% block content %} {% origin %} -

NomCom {{ year }} Private area

+

NomCom {{ year }} {% if nomcom.group.state_id == 'conclude' %}(Concluded){% endif %} Private area {% if is_chair_task %}- Chair/Advisors only{% endif %}

+
+ {% endfor %} {% if independent_feedback_types %}

Feedback not related to Nominees

diff --git a/ietf/templates/nomcom/view_feedback_nominee.html b/ietf/templates/nomcom/view_feedback_nominee.html index c19b3477e..2ce9c9877 100644 --- a/ietf/templates/nomcom/view_feedback_nominee.html +++ b/ietf/templates/nomcom/view_feedback_nominee.html @@ -23,7 +23,7 @@ {% if feedback.type.slug == ft.slug %} {% if forloop.first %}

{% else %}
{% endif %}
-
From
+
{% if feedback.time > last_seen_time %}New{% endif %}From
{{ feedback.author|formatted_email|default:"Anonymous" }} {% if ft.slug == "nomina" and feedback.nomination_set.first.share_nominator %} OK to share name with nominee diff --git a/ietf/templates/nomcom/view_feedback_pending.html b/ietf/templates/nomcom/view_feedback_pending.html index 9a418a1bd..f38a80a24 100644 --- a/ietf/templates/nomcom/view_feedback_pending.html +++ b/ietf/templates/nomcom/view_feedback_pending.html @@ -3,17 +3,24 @@ {% load origin %} {% load bootstrap3 %} +{% load staticfiles %} {% load nomcom_tags %} +{% block pagehead %} + + +{% endblock %} + {% block subtitle %} - Feeback pending{% endblock %} +{% block morecss %} +.nominee_multi_select { resize: vertical; } +{% endblock %} + {% block nomcom_content %} {% origin %}

Feedback pending from email list

- {% if message %} -

{{ message.1 }}

- {% endif %} {% if formset.forms %}
@@ -145,9 +152,6 @@ {% buttons %} - {% if default_type %} - - {% endif %} {% endbuttons %} {% endif %}
@@ -157,3 +161,8 @@ {% endif %} {% endblock %} + +{% block js %} + + +{% endblock %} diff --git a/ietf/templates/nomcom/view_feedback_unrelated.html b/ietf/templates/nomcom/view_feedback_unrelated.html index 1bb0a3608..391067079 100644 --- a/ietf/templates/nomcom/view_feedback_unrelated.html +++ b/ietf/templates/nomcom/view_feedback_unrelated.html @@ -25,23 +25,7 @@
From
{{ feedback.author|formatted_email|default:"Anonymous" }}
Date
-
{{ feedback.time|date:"Y-m-d" }})
- - {% if ft.slug == "nomina" %} - {% for fn in feedback.nomination_set.all %} - {% if fn.candidate_name %} -
Nominee
-
{{ fn.candidate_name }}
- {% endif %} - {% if fn.candidate_phone %} -
Nominee phone
-
{{ fn.candidate_phone }}
- {% endif %} - {% endfor %} - {% endif %} - -
Positions
-
{{ feedback.positions.all|join:"," }}
+
{{ feedback.time|date:"Y-m-d" }}
Body
{% decrypt feedback.comments request year 1 %}
diff --git a/requirements.txt b/requirements.txt index 203206c56..87a08d5fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,5 @@ six>=1.8.0 wsgiref>=0.1.2 xml2rfc>=2.5.0 django>=1.7.10,<1.8 +factory-boy>=2.6.0 +Unidecode>=0.4.18