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/doc/tests_draft.py b/ietf/doc/tests_draft.py index 360145378..c50a9c43a 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -1268,6 +1268,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", @@ -1278,6 +1279,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", @@ -1288,6 +1290,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", @@ -1298,6 +1301,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")) @@ -1332,8 +1336,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 @@ -1344,9 +1348,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() @@ -1355,9 +1359,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() @@ -1365,8 +1369,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/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 8baaddb78..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": "" @@ -1580,6 +1592,16 @@ "model": "name.liaisonstatementeventtypename", "pk": "comment" }, +{ + "fields": { + "order": 10, + "used": true, + "name": "Private Comment", + "desc": "" + }, + "model": "name.liaisonstatementeventtypename", + "pk": "private_comment" +}, { "fields": { "order": 1, @@ -4541,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, @@ -4701,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 ", @@ -5306,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" }, @@ -5698,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": [ @@ -5843,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/forms.py b/ietf/nomcom/forms.py index 629fd8841..3f96b24ac 100644 --- a/ietf/nomcom/forms.py +++ b/ietf/nomcom/forms.py @@ -11,7 +11,7 @@ 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, @@ -19,7 +19,7 @@ from ietf.nomcom.utils import (NOMINATION_RECEIPT_TEMPLATE, FEEDBACK_RECEIPT_TEM 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 @@ -223,85 +223,31 @@ class EditNomcomForm(forms.ModelForm): 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'})) + 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_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 - - 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) + primary_person = self.cleaned_data.get("primary_person") + duplicate_persons = self.cleaned_data.get("duplicate_persons") + 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) class NominateForm(forms.ModelForm): searched_email = SearchableEmailField(only_users=False) @@ -813,45 +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/tests.py b/ietf/nomcom/tests.py index b1260c8a4..5ccbc4c34 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -4,6 +4,7 @@ import datetime import os import shutil from pyquery import PyQuery +import StringIO from django.db import IntegrityError from django.db.models import Max @@ -21,6 +22,8 @@ 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 @@ -34,7 +37,7 @@ from ietf.nomcom.management.commands.send_reminders import Command, is_time_to_s 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 +from ietf.person.factories import PersonFactory, EmailFactory, UserFactory from ietf.dbtemplate.factories import DBTemplateFactory from ietf.dbtemplate.models import DBTemplate @@ -169,205 +172,6 @@ class NomcomViewsTest(TestCase): 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, @@ -1662,8 +1466,35 @@ Junk body for testing response = self.client.get(url) self.assertEqual(response.status_code, 404) - # Note that the old tests currently test the edit_position view through its nomcom_add_position name - + 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): @@ -1705,3 +1536,32 @@ class NoPublicKeyTests(TestCase): # 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/views.py b/ietf/nomcom/views.py index b8f45301b..6326f6f42 100644 --- a/ietf/nomcom/views.py +++ b/ietf/nomcom/views.py @@ -271,10 +271,11 @@ def private_merge(request, year): form = None else: if request.method == 'POST': - form = MergeForm(request.POST, nomcom=nomcom) + form = MergeForm(request.POST, nomcom=nomcom ) if form.is_valid(): form.save() - messages.success(request, 'The emails have been unified') + messages.success(request, 'A merge request has been sent to the secretariat.') + return redirect('nomcom_private_index',year=year) else: form = MergeForm(nomcom=nomcom) @@ -784,7 +785,8 @@ def edit_nominee(request, year, nominee_id): instance=nominee) if form.is_valid(): form.save() - messages.success(request, '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) diff --git a/ietf/person/factories.py b/ietf/person/factories.py index f638a3d48..2cc7e006e 100644 --- a/ietf/person/factories.py +++ b/ietf/person/factories.py @@ -53,6 +53,6 @@ class EmailFactory(factory.DjangoModelFactory): model = Email django_get_or_create = ('address',) - address = '%s.%s@%s' % (factory.Faker('first_name'),factory.Faker('last_name'),factory.Faker('domain_name')) + 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/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/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/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 20c4779c6..78fe8256a 100644 --- a/ietf/templates/nomcom/nomcom_private_base.html +++ b/ietf/templates/nomcom/nomcom_private_base.html @@ -33,7 +33,7 @@
  • Enter questionnaire response
  • Send accept reminder
  • Send questionnaire reminder
  • -
  • Merge Email Addresses
  • +
  • Request Nominee Merge
  • {% endif %}
  • Edit Settings
  • @@ -45,29 +45,6 @@
  • Configuration Help
  • - - {% comment %} - - - - {% endcomment %} {% endif %} diff --git a/ietf/templates/nomcom/private_merge.html b/ietf/templates/nomcom/private_merge.html index 8c0662f07..fce825ec2 100644 --- a/ietf/templates/nomcom/private_merge.html +++ b/ietf/templates/nomcom/private_merge.html @@ -1,29 +1,37 @@ {% extends "nomcom/nomcom_private_base.html" %} {# Copyright The IETF Trust 2015, All Rights Reserved #} {% load origin %} +{% load staticfiles %} {% load bootstrap3 %} -{% block subtitle %} - Merging emails {% endblock %} +{% block pagehead %} + + +{% endblock %} + +{% block subtitle %} - Request Nominee Merge {% endblock %} {% block nomcom_content %} {% origin %} -

    Merging nominee email addresses

    +

    Request Nominee Merge

    - If a nominee has been nominated with multiple email addresses, the nominee will - appear multiple times in the nomination list, as the email address is used as - the unique identifier for each nominee. In order to permit comments and nominations - to be submitted under multiple email addresses, there is a list of secondary email - addresses which needs to be kept up-to-date. When nominations of one particular nominee - have already been made under different email addresses, the nomination comments from the - secondary address also needs to be merged with those under the primary address. + The nomination system encourages the community to nominate people by selecting + their email address from the set of addresses the tracker already knows. In order + to allow a person who does not yet have a datatracker account to be nominated, the + system also provides a way for the community to nominate people with a new, + previously unknown email address. When this option is chosen, a new Person record + is created and associated with the new address.

    -

    - It doesn't matter particularly which email address is used as primary, as far as the - nominee information maintenance goes, but it's probably handier for the nomcom if the - primary address is the one which the nominee prefers at the time. + Occasionally, this new address should have been associated with an existing person + instead. This will happen particularly if the community member uses a slightly incorrect + address (such as a typo), or knows the person they want to nominate by a very old or very + new address that is not yet in the tracker. When this happens, you can use this form to + ask the secretariat to merge the two Person records. The secretariat has a process + for verifying that the addresses both belong to the same person, and a tool that + can correct the relevant data.

    {% if form %} @@ -38,3 +46,8 @@ {% endif %} {% endblock %} + +{% block js %} + + +{% endblock %}