Improved SearchablePersonField to show the primary email address for any search results where a name appears more than once.

Simplified the edit nominee form.
Replaced the merge nominee form with a request to the secretariat to merge Person records. Fixes #1847.
Added merging nominees to the secretariat's person merging script.
Restructured the person merging script to make it testable.
Updated some tests to match changes to the mailtriggers that hadn't made it to the fixtures.
 - Legacy-Id: 10625
This commit is contained in:
Robert Sparks 2015-12-22 21:42:55 +00:00
parent aadcf2d056
commit dc5ae398f2
15 changed files with 331 additions and 420 deletions

View file

@ -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)

View file

@ -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):

View file

@ -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):

View file

@ -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)
]

View file

@ -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": "<i-d-announce@ietf.org>",
"desc": "The I-D-Announce Email List"
},
"model": "mailtrigger.recipient",
"pk": "id_announce"
},
{
"fields": {
"template": "The IESG <iesg@ietf.org>",
@ -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"
},

View file

@ -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.")

View file

@ -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())

View file

@ -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)

View file

@ -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

View file

@ -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

88
ietf/person/utils.py Executable file
View file

@ -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()

View file

@ -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]))

View file

@ -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.

View file

@ -33,7 +33,7 @@
<li {% if selected == "questionnaire" %}class="active"{% endif %}><a href="{% url "nomcom_private_questionnaire" year %}">Enter questionnaire response</a></li>
<li {% if selected == "send_accept_reminder" %}class="active"{% endif %}><a href="{% url "nomcom_send_reminder_mail" year "accept" %}">Send accept reminder</a></li>
<li {% if selected == "send_questionnaire_reminder" %}class="active"{% endif %}><a href="{% url "nomcom_send_reminder_mail" year "questionnaire" %}">Send questionnaire reminder</a></li>
<li {% if selected == "merge" %}class="active"{% endif %}><a href="{% url "nomcom_private_merge" year %}">Merge Email Addresses</a></li>
<li {% if selected == "merge" %}class="active"{% endif %}><a href="{% url "nomcom_private_merge" year %}">Request Nominee Merge</a></li>
{% endif %}
<li role = "presentation" class = "dropdown-header">Nomcom Configuration</li>
<li {% if selected == "edit_nomcom" %}class="active"{% endif %}><a href="{% url "nomcom_edit_nomcom" year %}">Edit Settings</a></li>
@ -45,29 +45,6 @@
<li {% if selected == "help" %}class="active"{% endif %}><a href="{% url "nomcom_chair_help" year %}">Configuration Help</a></li>
</ul>
</li>
{% comment %}
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#">Chair <span class="caret"></span></a>
<ul class="dropdown-menu" role="menu">
<li {% if selected == "feedback_pending" %}class="active"{% endif %}><a href="{% url "nomcom_view_feedback_pending" year %}">Pending feedback</a></li>
<li {% if selected == "feedback_email" %}class="active"{% endif %}><a href="{% url "nomcom_private_feedback_email" year %}">Enter email feedback</a></li>
<li {% if selected == "send_accept_reminder" %}class="active"{% endif %}><a href="{% url "nomcom_send_reminder_mail" year "accept" %}">Send accept reminder</a></li>
<li {% if selected == "send_questionnaire_reminder" %}class="active"{% endif %}><a href="{% url "nomcom_send_reminder_mail" year "questionnaire" %}">Send questionnaire reminder</a></li>
</ul>
</li>
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#">Edit <span class="caret"></span></a>
<ul class="dropdown-menu" role="menu">
<li {% if selected == "edit_nomcom" %}class="active"{% endif %}><a href="{% url "nomcom_edit_nomcom" year %}">Edit Settings</a></li>
<li {% if selected == "edit_templates" %}class="active"{% endif %}><a href="{% url "nomcom_list_templates" year %}">Edit Pages</a></li>
<li {% if selected == "edit_positions" %}class="active"{% endif %}><a href="{% url "nomcom_list_positions" year %}">Edit Positions</a></li>
<li {% if selected == "merge" %}class="active"{% endif %}><a href="{% url "nomcom_private_merge" year %}">Merge Email Addresses</a></li>
<li {% if selected == "edit_members" %}class="active"{% endif %}><a href="{% url "nomcom_edit_members" year %}">Edit Nomcom Members</a></li>
</ul>
</li>
{% endcomment %}
{% endif %}
</ul>

View file

@ -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 %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{% endblock %}
{% block subtitle %} - Request Nominee Merge {% endblock %}
{% block nomcom_content %}
{% origin %}
<h2>Merging nominee email addresses</h2>
<h2>Request Nominee Merge</h2>
<p>
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.
</p>
<p>
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.
</p>
{% if form %}
@ -38,3 +46,8 @@
{% endif %}
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{% endblock %}