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 -----------------
616import os , sys
717basedir = os .path .abspath (os .path .join (os .path .dirname (__file__ ), "../.." ))
818sys .path = [ basedir ] + sys .path
919os .environ .setdefault ("DJANGO_SETTINGS_MODULE" , "ietf.settings" )
1020
1121import django
1222django .setup ()
23+ # -------------------------------------------------------------------------------------
1324
1425import argparse
1526import pprint
27+ import syslog
1628from django .contrib import admin
1729from django .contrib .auth .models import User
1830from 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 ()
0 commit comments