1 | #!/usr/bin/python |
---|
2 | import collections |
---|
3 | import datetime |
---|
4 | import os |
---|
5 | import sys |
---|
6 | |
---|
7 | if __name__ == '__main__': |
---|
8 | cur_file = os.path.abspath(__file__) |
---|
9 | django_dir = os.path.abspath(os.path.join(os.path.dirname(cur_file), '..')) |
---|
10 | sys.path.append(django_dir) |
---|
11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'settings' |
---|
12 | |
---|
13 | from django.conf import settings |
---|
14 | from django.contrib.contenttypes.models import ContentType |
---|
15 | from django.core.mail import EmailMessage, mail_admins |
---|
16 | from django.db import connection |
---|
17 | from django.db.models import Q |
---|
18 | from django.template import Context, Template |
---|
19 | from django.template.loader import get_template |
---|
20 | |
---|
21 | import reversion.models |
---|
22 | |
---|
23 | import groups.models |
---|
24 | import util.emails |
---|
25 | import util.mailinglist |
---|
26 | |
---|
27 | if settings.PRODUCTION_DEPLOYMENT: |
---|
28 | asa_all_groups_list = util.mailinglist.MailmanList('asa-official') |
---|
29 | gsc_fb_list = util.mailinglist.MailmanList('gsc-fb-') |
---|
30 | finboard_groups_list = util.mailinglist.MoiraList('finboard-groups-only') |
---|
31 | else: |
---|
32 | asa_all_groups_list = util.mailinglist.MailmanList('asa-test-mailman') |
---|
33 | gsc_fb_list = util.mailinglist.MailmanList('asa-test-mailman') |
---|
34 | finboard_groups_list = util.mailinglist.MoiraList('asa-test-moira') |
---|
35 | |
---|
36 | class DiffCallback(object): |
---|
37 | def start_run(self, since, now, ): |
---|
38 | pass |
---|
39 | def end_run(self, ): |
---|
40 | pass |
---|
41 | def handle_group(self, before, after, before_fields, after_fields, ): |
---|
42 | pass |
---|
43 | def handle_signatories(self, signatories, ): |
---|
44 | pass |
---|
45 | def new_group(self, after, after_fields, ): |
---|
46 | pass |
---|
47 | |
---|
48 | class StaticMailCallback(DiffCallback): |
---|
49 | def __init__(self, fields, address, template, signatories=[]): |
---|
50 | self.fields = fields |
---|
51 | self.address = address |
---|
52 | self.template = template |
---|
53 | self.interesting_signatories = signatories |
---|
54 | self.care_about_groups = True |
---|
55 | self.care_about_signatories = True |
---|
56 | |
---|
57 | def start_run(self, since, now, ): |
---|
58 | self.updates = [] |
---|
59 | self.signatory_updates = [] |
---|
60 | self.signatory_type_counts = { |
---|
61 | 'Added': collections.defaultdict(lambda: 0), |
---|
62 | 'Expired': collections.defaultdict(lambda: 0), |
---|
63 | } |
---|
64 | self.signatory_types_seen = set() |
---|
65 | self.since = since |
---|
66 | self.now = now |
---|
67 | |
---|
68 | def handle_group(self, before, after, before_fields, after_fields, ): |
---|
69 | after_revision = after.revision |
---|
70 | update = "Group: %s (ID #%d)\n" % (after_fields['name'], after_fields['id'], ) |
---|
71 | update += " At %s by %s (and possibly other people or times)\n" % ( |
---|
72 | after_revision.date_created, after_revision.user, ) |
---|
73 | for field in self.fields: |
---|
74 | if before_fields[field] != after_fields[field]: |
---|
75 | update += ' %18s: %12s -> %12s\n' % ( |
---|
76 | field, before_fields[field], after_fields[field], ) |
---|
77 | self.updates.append(update) |
---|
78 | |
---|
79 | def handle_signatories(self, signatories, ): |
---|
80 | prev_group = None |
---|
81 | for signatory in signatories: |
---|
82 | if signatory.end_time > self.now: |
---|
83 | change_type = "Added" |
---|
84 | else: |
---|
85 | change_type = "Expired" |
---|
86 | counter = self.signatory_type_counts[change_type] |
---|
87 | counter[signatory.role.slug] += 1 |
---|
88 | self.signatory_types_seen.add(signatory.role.slug) |
---|
89 | if signatory.role.slug in self.interesting_signatories: |
---|
90 | if signatory.group != prev_group: |
---|
91 | self.signatory_updates.append("") |
---|
92 | self.signatory_updates.append( |
---|
93 | "%s: %s: %s: %s:\n\trange %s to %s" % ( |
---|
94 | change_type, |
---|
95 | signatory.group, |
---|
96 | signatory.role, |
---|
97 | signatory.person, |
---|
98 | signatory.start_time.strftime(settings.DATETIME_FORMAT_PYTHON), |
---|
99 | signatory.end_time.strftime(settings.DATETIME_FORMAT_PYTHON), |
---|
100 | )) |
---|
101 | prev_group = signatory.group |
---|
102 | else: |
---|
103 | pass |
---|
104 | #print "Ignoring role %s (signatory %s)" % (signatory.role.slug, signatory, ) |
---|
105 | |
---|
106 | def build_change_stats(self, ): |
---|
107 | lines = [] |
---|
108 | care_about = 0 |
---|
109 | |
---|
110 | line = "%20s" % ("", ) |
---|
111 | change_types = self.signatory_type_counts.keys() |
---|
112 | for change_type in change_types: |
---|
113 | line += "\t%s" % (change_type, ) |
---|
114 | lines.append(line); line = "" |
---|
115 | |
---|
116 | for sig_type in self.signatory_types_seen: |
---|
117 | anno_sig = sig_type |
---|
118 | if sig_type in self.interesting_signatories: |
---|
119 | anno_sig += " " |
---|
120 | else: |
---|
121 | anno_sig += "*" |
---|
122 | line += "%20s" % (anno_sig, ) |
---|
123 | for change_type in change_types: |
---|
124 | if sig_type in self.signatory_type_counts[change_type]: |
---|
125 | count = self.signatory_type_counts[change_type][sig_type] |
---|
126 | else: |
---|
127 | count = 0 |
---|
128 | if sig_type in self.interesting_signatories: |
---|
129 | care_about += count |
---|
130 | out = "\t%4d" % (count, ) |
---|
131 | line += out |
---|
132 | lines.append(line); line = "" |
---|
133 | |
---|
134 | line = "* Details for this signatory type not included in email." |
---|
135 | lines.append(line) |
---|
136 | |
---|
137 | return "\n".join(lines), care_about |
---|
138 | |
---|
139 | def end_run(self, ): |
---|
140 | change_stats, care_about = self.build_change_stats() |
---|
141 | print "\nChange stats for email to %s:" % (self.address, ) |
---|
142 | print change_stats |
---|
143 | |
---|
144 | message = "\n\n".join(self.updates) |
---|
145 | signatories_message = "\n".join(self.signatory_updates) |
---|
146 | if (self.care_about_groups and self.updates) or (self.care_about_signatories and self.signatory_updates): |
---|
147 | pass |
---|
148 | else: |
---|
149 | return |
---|
150 | tmpl = get_template(self.template) |
---|
151 | ctx = Context({ |
---|
152 | 'num_groups': len(self.updates), |
---|
153 | 'groups_message': message, |
---|
154 | 'care_about': care_about, |
---|
155 | 'change_stats': change_stats, |
---|
156 | 'signatory_types': self.interesting_signatories, |
---|
157 | 'signatories_message': signatories_message, |
---|
158 | }) |
---|
159 | body = tmpl.render(ctx) |
---|
160 | email = EmailMessage( |
---|
161 | subject='ASA Database Updates', |
---|
162 | body=body, |
---|
163 | from_email='asa-db@mit.edu', |
---|
164 | to=[self.address, ], |
---|
165 | bcc=['asa-db-outgoing@mit.edu', ] |
---|
166 | ) |
---|
167 | email.send() |
---|
168 | self.updates = [] |
---|
169 | self.signatory_updates = [] |
---|
170 | |
---|
171 | |
---|
172 | class UpdateOfficerListCallback(DiffCallback): |
---|
173 | def __init__(self, listobj, include_pred=None): |
---|
174 | self.listobj = listobj |
---|
175 | if not include_pred: |
---|
176 | include_pred = lambda version, fields: True |
---|
177 | self.include_pred = include_pred |
---|
178 | |
---|
179 | def start_run(self, since, now, ): |
---|
180 | self.add = [] |
---|
181 | self.delete = [] |
---|
182 | self.notes = [] |
---|
183 | |
---|
184 | def end_run(self, ): |
---|
185 | if self.add or self.delete: |
---|
186 | errors = self.listobj.change_members(self.add, self.delete) |
---|
187 | listname = self.listobj.name |
---|
188 | subject = "[ASA DB] %s updater" % (listname, ) |
---|
189 | if errors: |
---|
190 | subject = "ERROR: " + subject |
---|
191 | context = { |
---|
192 | 'listname': listname, |
---|
193 | 'add': self.add, |
---|
194 | 'delete': self.delete, |
---|
195 | 'errors': errors, |
---|
196 | 'notes': self.notes, |
---|
197 | } |
---|
198 | util.emails.email_from_template( |
---|
199 | tmpl='groups/diffs/list-update.txt', |
---|
200 | context=context, subject=subject, |
---|
201 | to=['asa-db-auto@mit.edu'], |
---|
202 | ).send() |
---|
203 | |
---|
204 | def update_changes(self, change_list, phase_name, name, addr, include, force_note=False, ): |
---|
205 | """ |
---|
206 | Given an address and whether to process this item, update a list as appropriate and supply appropriate diagnostic notes. |
---|
207 | """ |
---|
208 | |
---|
209 | note = None |
---|
210 | if addr and include: |
---|
211 | change_list.append((name, addr, )) |
---|
212 | if force_note: |
---|
213 | note = "email address is %s" % (addr, ) |
---|
214 | elif not include: |
---|
215 | note = "doesn't pass predicate" |
---|
216 | elif not addr: |
---|
217 | note = "address is blank" |
---|
218 | else: |
---|
219 | note = "Something weird happened while adding (addr='%s', include=%s)" % (addr, include) |
---|
220 | if note: |
---|
221 | self.notes.append("%8s: %s: %s" % (phase_name, name, note, )) |
---|
222 | |
---|
223 | def handle_group(self, before, after, before_fields, after_fields, ): |
---|
224 | before_include = self.include_pred(before, before_fields) |
---|
225 | after_include = self.include_pred(after, after_fields) |
---|
226 | before_addr = before_fields['officer_email'] |
---|
227 | after_addr = after_fields['officer_email'] |
---|
228 | |
---|
229 | # check if a change is appropriate |
---|
230 | effective_before_addr = before_addr if before_include else None |
---|
231 | effective_after_addr = after_addr if after_include else None |
---|
232 | |
---|
233 | if effective_before_addr != effective_after_addr: |
---|
234 | name = after_fields['name'] |
---|
235 | self.update_changes(self.delete, "Delete", name, before_addr, before_include) |
---|
236 | self.update_changes(self.add, "Add", name, after_addr, after_include) |
---|
237 | |
---|
238 | def new_group(self, after, after_fields, ): |
---|
239 | name = after_fields['name'] |
---|
240 | email = after_fields['officer_email'] |
---|
241 | include = self.include_pred(after, after_fields) |
---|
242 | self.update_changes(self.add, "New", name, email, include, force_note=True) |
---|
243 | |
---|
244 | |
---|
245 | def default_active_pred(): |
---|
246 | status_objs = groups.models.GroupStatus.objects.filter(slug__in=['active', 'suspended', ]) |
---|
247 | status_pks = [status.pk for status in status_objs] |
---|
248 | def pred(version, fields): |
---|
249 | return fields['group_status'] in status_pks |
---|
250 | return pred |
---|
251 | |
---|
252 | def funded_pred(funding_slug): |
---|
253 | classes = groups.models.GroupClass.objects |
---|
254 | class_pk = classes.get(slug='mit-funded').pk |
---|
255 | fundings = groups.models.GroupFunding.objects |
---|
256 | fund_pk = fundings.get(slug=funding_slug).pk |
---|
257 | active_pred = default_active_pred() |
---|
258 | def pred(version, fields): |
---|
259 | return active_pred(version, fields) and fields['group_class'] == class_pk and fields['group_funding'] == fund_pk |
---|
260 | return pred |
---|
261 | |
---|
262 | def build_callbacks(): |
---|
263 | callbacks = [] |
---|
264 | callbacks.append(StaticMailCallback( |
---|
265 | fields=[ |
---|
266 | 'name', 'abbreviation', |
---|
267 | 'officer_email', 'group_email', 'athena_locker', |
---|
268 | 'website_url', 'constitution_url', |
---|
269 | 'activity_category', 'group_class', 'group_status', 'group_funding', |
---|
270 | 'advisor_name', |
---|
271 | 'num_undergrads', 'num_grads', 'num_community', 'num_other', |
---|
272 | 'main_account_id', 'funding_account_id', |
---|
273 | ], |
---|
274 | address='asa-internal@mit.edu', # some of these fields aren't public |
---|
275 | template='groups/diffs/asa-update-mail.txt', |
---|
276 | signatories=['president', 'treasurer', 'financial', 'group-admin', 'temp-admin', ] |
---|
277 | )) |
---|
278 | sao_callback = StaticMailCallback( |
---|
279 | fields=['name', 'abbreviation', 'officer_email', ], |
---|
280 | address='funds@mit.edu', |
---|
281 | template='groups/diffs/sao-update-mail.txt', |
---|
282 | signatories=['president', 'treasurer', 'financial', ] |
---|
283 | ) |
---|
284 | sao_callback.care_about_groups = False |
---|
285 | callbacks.append(sao_callback) |
---|
286 | callbacks.append(UpdateOfficerListCallback( |
---|
287 | listobj=asa_all_groups_list, |
---|
288 | include_pred=default_active_pred(), |
---|
289 | )) |
---|
290 | callbacks.append(UpdateOfficerListCallback( |
---|
291 | listobj=finboard_groups_list, |
---|
292 | include_pred=funded_pred('undergrad'), |
---|
293 | )) |
---|
294 | callbacks.append(UpdateOfficerListCallback( |
---|
295 | listobj=gsc_fb_list, |
---|
296 | include_pred=funded_pred('grad'), |
---|
297 | )) |
---|
298 | return callbacks |
---|
299 | |
---|
300 | def recent_groups(since): |
---|
301 | group_type = ContentType.objects.get_by_natural_key(app_label='groups', model='group') |
---|
302 | revisions = reversion.models.Revision.objects.filter(date_created__gte=since) |
---|
303 | versions = reversion.models.Version.objects.filter(revision__in=revisions, content_type=group_type) |
---|
304 | objs = versions.values("content_type", "object_id").distinct() |
---|
305 | return objs |
---|
306 | |
---|
307 | def diff_objects(objs, since, callbacks, stats, ): |
---|
308 | revs = reversion.models.Revision.objects.all() |
---|
309 | old_revs = revs.filter(date_created__lte=since) |
---|
310 | new_revs = revs.filter(date_created__gte=since) |
---|
311 | for obj in objs: |
---|
312 | all_versions = reversion.models.Version.objects.filter(content_type=obj['content_type'], object_id=obj['object_id']).order_by('-revision__date_created') |
---|
313 | before_versions = all_versions.filter(revision__in=old_revs)[:1] |
---|
314 | # This object being passed in means that some version changed it. |
---|
315 | after_versions = all_versions.filter(revision__in=new_revs).select_related('revision__user') |
---|
316 | after = after_versions[0] |
---|
317 | after_fields = after.field_dict |
---|
318 | |
---|
319 | if len(before_versions) > 0 or len(after_versions) > 1: |
---|
320 | if len(before_versions) > 0: |
---|
321 | before = before_versions[0] |
---|
322 | stats['change_old'] += 1 |
---|
323 | else: |
---|
324 | # New group that's been edited since. Diff against the creation |
---|
325 | # (since creation sent mail, but later changes haven't) |
---|
326 | before = after_versions.reverse()[0] |
---|
327 | stats['change_new'] += 1 |
---|
328 | stats['change_total'] += 1 |
---|
329 | #print "Change?: before=%s (%d), after=%s (%d), type=%s, new=%s" % ( |
---|
330 | # before, before.pk, |
---|
331 | # after, after.pk, |
---|
332 | # after.type, after.field_dict, |
---|
333 | #) |
---|
334 | before_fields = before.field_dict |
---|
335 | for callback in callbacks: |
---|
336 | callback.handle_group(before, after, before_fields, after_fields) |
---|
337 | else: |
---|
338 | # New group that's only been edited once |
---|
339 | # Note that "normal" new groups will have their startup form |
---|
340 | # (which creates the Group object) and the approval (which makes |
---|
341 | # more changes, so this is group startups + NGEs, not actually |
---|
342 | # normal new groups) |
---|
343 | stats['new_group'] += 1 |
---|
344 | callback.new_group(after, after_fields) |
---|
345 | |
---|
346 | def diff_signatories(since, now, callbacks): |
---|
347 | # First: still around; then added recently |
---|
348 | qobj_added = Q(end_time__gte=now, start_time__gte=since) |
---|
349 | # First: already gone; then it existed for a while; finally expired recently |
---|
350 | qobj_expired = Q(end_time__lte=now, start_time__lte=since, end_time__gte=since) |
---|
351 | changed_signatories = groups.models.OfficeHolder.objects.filter(qobj_added|qobj_expired) |
---|
352 | changed_signatories = changed_signatories.order_by('group__name', 'role__display_name', 'person', ) |
---|
353 | changed_signatories = changed_signatories.select_related('role', 'group') |
---|
354 | for callback in callbacks: callback.handle_signatories(changed_signatories) |
---|
355 | |
---|
356 | def generate_diffs(): |
---|
357 | now = datetime.datetime.now() |
---|
358 | recent = now - datetime.timedelta(hours=24, minutes=15) |
---|
359 | objs = recent_groups(since=recent) |
---|
360 | callbacks = build_callbacks() |
---|
361 | stats = collections.defaultdict(lambda: 0) |
---|
362 | for callback in callbacks: callback.start_run(since=recent, now=now, ) |
---|
363 | diff_objects(objs, since=recent, callbacks=callbacks, stats=stats) |
---|
364 | diff_signatories(recent, now, callbacks=callbacks, ) |
---|
365 | for callback in callbacks: callback.end_run() |
---|
366 | |
---|
367 | print "\nOverall change stats:" |
---|
368 | for stat_key, stat_val in stats.items(): |
---|
369 | print "%20s:\t%6d" % (stat_key, stat_val, ) |
---|
370 | print "" |
---|
371 | |
---|
372 | if __name__ == '__main__': |
---|
373 | generate_diffs() |
---|