Skip to content

Commit e97cd64

Browse files
committed
Rewrite merge-person-records utility script to handle all related objects in a generic manner, remove old User records and handle email primary attribute. Fixes ietf-tools#1627. Commit ready for merge
- Legacy-Id: 10721
1 parent 57f3acb commit e97cd64

4 files changed

Lines changed: 190 additions & 75 deletions

File tree

ietf/bin/merge-person-records

Lines changed: 160 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,90 +2,180 @@
22
# -*- coding: utf-8 -*-
33
# -*- Python -*-
44
#
5+
'''
6+
This script merges two Person records into one. It determines which record is the target
7+
based on most current User record (last_login) unless -f (force) option is used to
8+
force SOURCE TARGET as specified on the command line. The order of operations is
9+
important. We must complete all source.save() operations before moving the aliases to
10+
the target, this is to avoid extra "Possible duplicate Person" emails going out, if the
11+
Person is saved without an alias the Person.save() creates another one, which then
12+
conflicts with the moved one.
13+
'''
514

15+
# Set PYTHONPATH and load environment variables for standalone script -----------------
616
import os, sys
717
basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
818
sys.path = [ basedir ] + sys.path
919
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ietf.settings")
1020

1121
import django
1222
django.setup()
23+
# -------------------------------------------------------------------------------------
1324

1425
import argparse
1526
import pprint
27+
import syslog
1628
from django.contrib import admin
1729
from django.contrib.auth.models import User
1830
from ietf.person.models import Person
31+
from ietf.utils.log import log
32+
from ietf.utils.mail import send_mail
1933

20-
parser = argparse.ArgumentParser()
21-
parser.add_argument("source_id",type=int)
22-
parser.add_argument("target_id",type=int)
23-
args = parser.parse_args()
24-
25-
source = Person.objects.get(pk=args.source_id)
26-
target = Person.objects.get(pk=args.target_id)
27-
28-
print "Merging person {}({}) to {}({})".format(source.ascii,source.pk,target.ascii,target.pk)
29-
response = raw_input('Ok to continue y/n? ')
30-
if response.lower() != 'y':
31-
sys.exit()
32-
33-
# merge emails
34-
for email in source.email_set.all():
35-
print "Merging email: {}".format(email.address)
36-
email.person = target
37-
email.save()
38-
39-
# merge aliases
40-
target_aliases = [ a.name for a in target.alias_set.all() ]
41-
for alias in source.alias_set.all():
42-
if alias.name in target_aliases:
43-
alias.delete()
34+
def dedupe_aliaises(person):
35+
'''
36+
Check person for duplicate aliases and purge
37+
'''
38+
seen = []
39+
for alias in person.alias_set.all():
40+
if alias.name in seen:
41+
alias.delete()
42+
else:
43+
seen.append(alias.name)
44+
45+
def determine_merge_order(source,target):
46+
'''
47+
Determine merge order. Select Person that has related User. If both have Users
48+
select one with most recent login
49+
'''
50+
if source.user and not target.user:
51+
source,target = target,source # swap merge order
52+
if source.user and target.user:
53+
source,target = sorted([source,target],key=lambda a: a.user.last_login)
54+
return source,target
55+
56+
def get_extra_primary(source,target):
57+
'''
58+
Inspect email addresses and return list of those that should no longer be primary
59+
'''
60+
if source.email_set.filter(primary=True) and target.email_set.filter(primary=True):
61+
return source.email_set.filter(primary=True)
4462
else:
45-
print "Merging alias: {}".format(alias.name)
46-
alias.person = target
47-
alias.save()
48-
49-
# merge DocEvents
50-
for docevent in source.docevent_set.all():
51-
docevent.by = target
52-
docevent.save()
53-
54-
# merge SubmissionEvents
55-
for subevent in source.submissionevent_set.all():
56-
subevent.by = target
57-
subevent.save()
58-
59-
# merge Messages
60-
for message in source.message_set.all():
61-
message.by = target
62-
message.save()
63-
64-
# merge Constraints
65-
for constraint in source.constraint_set.all():
66-
constraint.person = target
67-
constraint.save()
68-
69-
# merge Roles
70-
for role in source.role_set.all():
71-
role.person = target
72-
role.save()
73-
74-
# check for any remaining relationships and delete if none
75-
objs = [source]
76-
opts = Person._meta
77-
user = User.objects.filter(is_superuser=True).first()
78-
admin_site = admin.site
79-
using = 'default'
80-
81-
deletable_objects, perms_needed, protected = admin.utils.get_deleted_objects(
82-
objs, opts, user, admin_site, using)
83-
84-
if len(deletable_objects) > 1:
85-
print "Not Deleting Person: {}({})".format(source.ascii,source.pk)
86-
print "Related objects remain:"
87-
pprint.pprint(deletable_objects[1])
88-
89-
else:
90-
print "Deleting Person: {}({})".format(source.ascii,source.pk)
63+
return []
64+
65+
def handle_users(source,target,check_only=False):
66+
'''
67+
Deletes extra Users. Retains target user. If check_only == True, just return a string
68+
describing action, otherwise perform user changes and return string.
69+
'''
70+
if not (source.user or target.user):
71+
return "DATATRACKER LOGIN ACTION: none (no login defined)"
72+
if not source.user and target.user:
73+
return "DATATRACKER LOGIN ACTION: retaining login {}".format(target.user)
74+
if source.user and not target.user:
75+
message = "DATATRACKER LOGIN ACTION: retaining login {}".format(source.user)
76+
if not check_only:
77+
target.user = source.user
78+
target.save()
79+
return message
80+
if source.user and target.user:
81+
message = "DATATRACKER LOGIN ACTION: retaining login: {}, removing login: {}".format(target.user,source.user)
82+
if not check_only:
83+
syslog.syslog('merge-person-records: deleting user {}'.format(source.user.username))
84+
user = source.user
85+
source.user = None
86+
source.save()
87+
user.delete()
88+
return message
89+
90+
def send_notification(person,changes):
91+
'''
92+
Send an email to the merge target (Person) notifying them of the changes
93+
'''
94+
send_mail(request = None,
95+
to = person.email_address(),
96+
frm = "IETF Secretariat <ietf-secretariat@ietf.org>",
97+
subject = "IETF Datatracker records merged",
98+
template = "utils/merge_person_records.txt",
99+
context = dict(person=person,changes='\n'.join(changes)),
100+
extra = {}
101+
)
102+
103+
def main():
104+
parser = argparse.ArgumentParser()
105+
parser.add_argument("source_id",type=int)
106+
parser.add_argument("target_id",type=int)
107+
parser.add_argument('-f','--force', help='force merge order',action='store_true')
108+
parser.add_argument('-v','--verbose', help='verbose output',action='store_true')
109+
args = parser.parse_args()
110+
changes = []
111+
syslog.openlog(os.path.basename(__file__), syslog.LOG_PID, syslog.LOG_USER)
112+
113+
source = Person.objects.get(pk=args.source_id)
114+
target = Person.objects.get(pk=args.target_id)
115+
116+
# set merge order
117+
if not args.force:
118+
source,target = determine_merge_order(source,target)
119+
120+
# confirm
121+
print "Merging person {}({}) to {}({})".format(source.ascii,source.pk,target.ascii,target.pk)
122+
print handle_users(source,target,check_only=True)
123+
response = raw_input('Ok to continue y/n? ')
124+
if response.lower() != 'y':
125+
sys.exit()
126+
127+
# write log
128+
syslog.syslog("Merging person records {} => {}".format(source.pk,target.pk))
129+
130+
# handle primary emails
131+
for email in get_extra_primary(source,target):
132+
email.primary = False
133+
email.save()
134+
changes.append('EMAIL ACTION: {} no longer marked as primary'.format(email.address))
135+
136+
# handle users
137+
changes.append(handle_users(source,target))
138+
139+
# find all related objects and migrate
140+
for related_object in source._meta.get_all_related_objects():
141+
accessor = related_object.get_accessor_name()
142+
field_name = related_object.field.name
143+
queryset = getattr(source, accessor).all()
144+
if args.verbose:
145+
print "Merging {}:{}".format(accessor,queryset.count())
146+
kwargs = { field_name:target }
147+
queryset.update(**kwargs)
148+
149+
# check aliases
150+
dedupe_aliaises(target)
151+
152+
# copy other attributes
153+
for field in ('ascii','ascii_short','address','affiliation'):
154+
if getattr(source,field) and not getattr(target,field):
155+
setattr(target,field,getattr(source,field))
156+
target.save()
157+
158+
# check for any remaining relationships and exit if more found
159+
objs = [source]
160+
opts = Person._meta
161+
user = User.objects.filter(is_superuser=True).first()
162+
admin_site = admin.site
163+
using = 'default'
164+
deletable_objects, perms_needed, protected = admin.utils.get_deleted_objects(
165+
objs, opts, user, admin_site, using)
166+
167+
if len(deletable_objects) > 1:
168+
print "Not Deleting Person: {}({})".format(source.ascii,source.pk)
169+
print "Related objects remain:"
170+
pprint.pprint(deletable_objects[1])
171+
sys.exit(1)
172+
173+
if args.verbose:
174+
print "Deleting Person: {}({})".format(source.ascii,source.pk)
91175
source.delete()
176+
177+
# send email notification
178+
send_notification(target,changes)
179+
180+
if __name__ == "__main__":
181+
main()

ietf/secr/proceedings/proc_utils.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import shutil
1111

1212
from django.conf import settings
13-
from django.shortcuts import render_to_response
13+
from django.shortcuts import render_to_response, render
1414

1515
from ietf.doc.models import Document, RelatedDocument, DocEvent, NewRevisionDocEvent, State
1616
from ietf.group.models import Group, Role
@@ -213,7 +213,7 @@ def write_html(path,content):
213213
# End Helper Functions
214214
# -------------------------------------------------
215215

216-
def create_interim_directory():
216+
def create_interim_directory(request):
217217
'''
218218
Create static Interim Meeting directory pages that will live in a different URL space than
219219
the secretariat Django project
@@ -222,7 +222,7 @@ def create_interim_directory():
222222
# produce date sorted output
223223
page = 'proceedings.html'
224224
meetings = InterimMeeting.objects.order_by('-date')
225-
response = render_to_response('proceedings/interim_directory.html',{'meetings': meetings})
225+
response = render(request, 'proceedings/interim_directory.html',{'meetings': meetings})
226226
path = os.path.join(settings.SECR_INTERIM_LISTING_DIR, page)
227227
f = open(path,'w')
228228
f.write(response.content)
@@ -232,7 +232,7 @@ def create_interim_directory():
232232
page = 'proceedings-bygroup.html'
233233
qs = InterimMeeting.objects.all()
234234
meetings = sorted(qs, key=lambda a: a.group().acronym)
235-
response = render_to_response('proceedings/interim_directory.html',{'meetings': meetings})
235+
response = render(request, 'proceedings/interim_directory.html',{'meetings': meetings})
236236
path = os.path.join(settings.SECR_INTERIM_LISTING_DIR, page)
237237
f = open(path,'w')
238238
f.write(response.content)

ietf/secr/proceedings/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ def interim(request, acronym):
485485
type_id='session',
486486
)
487487

488-
create_interim_directory()
488+
create_interim_directory(request)
489489
make_directories(meeting)
490490

491491
messages.success(request, 'Meeting created')
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{% filter wordwrap:73 %}
2+
Dear {{ person.plain_name }},
3+
4+
We have noticed that there are two records in the IETF Datatracker for a person named "{{ person.plain_name }}". These records have been merged.
5+
6+
The merged record is:
7+
8+
Name: {{ person.plain_name }}
9+
Aliases: {{ person.alias_set.all|join:", " }}
10+
Address: {{ person.address }}
11+
Affiliation: {{ person.affiliation }}
12+
User (login): {{ person.user.username }}
13+
14+
Emails:
15+
{% for email in person.email_set.all %}{% if email.active %}{{ email.address }}{% if email.primary %} (primary){% endif %}
16+
{% endif %}{% endfor %}
17+
18+
{% if changes %}Changes of special note:
19+
{{ changes }}{% endif %}
20+
21+
You can make changes to your profile here: https://datatracker.ietf.org/accounts/profile/
22+
23+
Thank You,
24+
The IETF Secretariat
25+
{% endfilter %}

0 commit comments

Comments
 (0)