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