source: asadb/groups/diffs.py

stable
Last change on this file was 3bca03c, checked in by Alex Dehnert <adehnert@…>, 10 years ago

Move emails off asa-db@, for easier filtering

Basically all mail to asa-db@ should be mail that really needs a human to look
at, not routine nightly emails.

  • Property mode set to 100755
File size: 14.8 KB
RevLine 
[b6c7a44]1#!/usr/bin/python
2import collections
3import datetime
4import os
5import sys
6
7if __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
[afc5348]13from django.conf import settings
[b6c7a44]14from django.contrib.contenttypes.models import ContentType
15from django.core.mail import EmailMessage, mail_admins
16from django.db import connection
17from django.db.models import Q
18from django.template import Context, Template
19from django.template.loader import get_template
20
21import reversion.models
22
23import groups.models
[4098256]24import util.emails
[ac9b167]25import util.mailinglist
[b6c7a44]26
[ea217bd]27if settings.PRODUCTION_DEPLOYMENT:
[ac9b167]28    asa_all_groups_list = util.mailinglist.MailmanList('asa-official')
[7077b1d]29    gsc_fb_list = util.mailinglist.MailmanList('gsc-fb-')
[ac9b167]30    finboard_groups_list = util.mailinglist.MoiraList('finboard-groups-only')
[ea217bd]31else:
[ac9b167]32    asa_all_groups_list = util.mailinglist.MailmanList('asa-test-mailman')
[7077b1d]33    gsc_fb_list = util.mailinglist.MailmanList('asa-test-mailman')
[ac9b167]34    finboard_groups_list = util.mailinglist.MoiraList('asa-test-moira')
[4098256]35
[b6c7a44]36class 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
[73f0faf]43    def handle_signatories(self, signatories, ):
44        pass
[b6c7a44]45    def new_group(self, after, after_fields, ):
46        pass
47
48class 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
[435ceab]54        self.care_about_groups = True
55        self.care_about_signatories = True
[b6c7a44]56
57    def start_run(self, since, now, ):
58        self.updates = []
59        self.signatory_updates = []
60        self.signatory_type_counts = {
[9a30b56]61            'Added': collections.defaultdict(lambda: 0),
62            'Expired': collections.defaultdict(lambda: 0),
[b6c7a44]63        }
[8254020]64        self.signatory_types_seen = set()
[b6c7a44]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'], )
[95f612b]71        update += "  At %s by %s (and possibly other people or times)\n" % (
[b6c7a44]72            after_revision.date_created, after_revision.user, )
73        for field in self.fields:
74            if before_fields[field] != after_fields[field]:
[95f612b]75                update += %18s: %12s -> %12s\n' % (
[b6c7a44]76                    field, before_fields[field], after_fields[field], )
77        self.updates.append(update)
78
79    def handle_signatories(self, signatories, ):
[7654a6d]80        prev_group = None
[b6c7a44]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]
[9a30b56]87            counter[signatory.role.slug] += 1
[8254020]88            self.signatory_types_seen.add(signatory.role.slug)
[b6c7a44]89            if signatory.role.slug in self.interesting_signatories:
[7654a6d]90                if signatory.group != prev_group:
91                    self.signatory_updates.append("")
[b6c7a44]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                    ))
[7654a6d]101                prev_group = signatory.group
[b6c7a44]102            else:
[8254020]103                pass
[4091199]104                #print "Ignoring role %s (signatory %s)" % (signatory.role.slug, signatory, )
[b6c7a44]105
[8254020]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:
[b2cee30]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, )
[8254020]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
[b2cee30]134        line = "* Details for this signatory type not included in email."
135        lines.append(line)
136
[8254020]137        return "\n".join(lines), care_about
138
[b6c7a44]139    def end_run(self, ):
[8254020]140        change_stats, care_about = self.build_change_stats()
[4091199]141        print "\nChange stats for email to %s:" % (self.address, )
[8254020]142        print change_stats
[4091199]143
[b6c7a44]144        message = "\n\n".join(self.updates)
145        signatories_message = "\n".join(self.signatory_updates)
[435ceab]146        if (self.care_about_groups and self.updates) or (self.care_about_signatories and self.signatory_updates):
[b6c7a44]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,
[8254020]154            'care_about': care_about,
155            'change_stats': change_stats,
[b6c7a44]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
[4098256]171
172class UpdateOfficerListCallback(DiffCallback):
[ac9b167]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
[4098256]179    def start_run(self, since, now, ):
180        self.add = []
181        self.delete = []
[74e9f09]182        self.notes = []
[4098256]183
184    def end_run(self, ):
185        if self.add or self.delete:
[ac9b167]186            errors = self.listobj.change_members(self.add, self.delete)
187            listname = self.listobj.name
188            subject = "[ASA DB] %s updater" % (listname, )
[4098256]189            if errors:
190                subject = "ERROR: " + subject
191            context = {
[ac9b167]192                'listname': listname,
[4098256]193                'add': self.add,
194                'delete': self.delete,
195                'errors': errors,
[74e9f09]196                'notes': self.notes,
[4098256]197            }
198            util.emails.email_from_template(
[14f594b]199                tmpl='groups/diffs/list-update.txt',
[4098256]200                context=context, subject=subject,
[3bca03c]201                to=['asa-db-auto@mit.edu'],
[4098256]202            ).send()
203
[ac9b167]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
[4098256]223    def handle_group(self, before, after, before_fields, after_fields, ):
[ac9b167]224        before_include = self.include_pred(before, before_fields)
225        after_include = self.include_pred(after, after_fields)
[74e9f09]226        before_addr = before_fields['officer_email']
227        after_addr  = after_fields['officer_email']
[ac9b167]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:
[fb76571]234            name = after_fields['name']
[ac9b167]235            self.update_changes(self.delete, "Delete", name, before_addr, before_include)
236            self.update_changes(self.add, "Add", name, after_addr, after_include)
[4098256]237
238    def new_group(self, after, after_fields, ):
[2c9e652]239        name = after_fields['name']
240        email = after_fields['officer_email']
[ac9b167]241        include = self.include_pred(after, after_fields)
242        self.update_changes(self.add, "New", name, email, include, force_note=True)
243
244
[ca9e802]245def default_active_pred():
[9af1bb4]246    status_objs = groups.models.GroupStatus.objects.filter(slug__in=['active', 'suspended', ])
[ca9e802]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
[ac9b167]252def 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
[ca9e802]257    active_pred = default_active_pred()
[ac9b167]258    def pred(version, fields):
[ca9e802]259        return active_pred(version, fields) and fields['group_class'] == class_pk and fields['group_funding'] == fund_pk
[ac9b167]260    return pred
[4098256]261
[b6c7a44]262def build_callbacks():
263    callbacks = []
264    callbacks.append(StaticMailCallback(
[d90059c]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        ],
[30380bb]274        address='asa-internal@mit.edu',     # some of these fields aren't public
[b6c7a44]275        template='groups/diffs/asa-update-mail.txt',
[99747bd]276        signatories=['president', 'treasurer', 'financial', 'group-admin', 'temp-admin', ]
[b6c7a44]277    ))
[435ceab]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)
[ca9e802]286    callbacks.append(UpdateOfficerListCallback(
287        listobj=asa_all_groups_list,
288        include_pred=default_active_pred(),
289    ))
[ac9b167]290    callbacks.append(UpdateOfficerListCallback(
291        listobj=finboard_groups_list,
292        include_pred=funded_pred('undergrad'),
293    ))
[7077b1d]294    callbacks.append(UpdateOfficerListCallback(
295        listobj=gsc_fb_list,
296        include_pred=funded_pred('grad'),
297    ))
[b6c7a44]298    return callbacks
299
300def 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
[4091199]307def diff_objects(objs, since, callbacks, stats, ):
[b6c7a44]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.
[b04e142]315        after_versions = all_versions.filter(revision__in=new_revs).select_related('revision__user')
316        after = after_versions[0]
[2c9e652]317        after_fields = after.field_dict
[b6c7a44]318
[b04e142]319        if len(before_versions) > 0 or len(after_versions) > 1:
320            if len(before_versions) > 0:
321                before = before_versions[0]
[4091199]322                stats['change_old'] += 1
[b04e142]323            else:
324                # New group that's been edited since. Diff against the creation
325                # (since creation sent mail, but later changes haven't)
[80a8145]326                before = after_versions.reverse()[0]
[4091199]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            #)
[b83db50]334            before_fields = before.field_dict
[b6c7a44]335            for callback in callbacks:
336                callback.handle_group(before, after, before_fields, after_fields)
337        else:
[b04e142]338            # New group that's only been edited once
[80a8145]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)
[4091199]343            stats['new_group'] += 1
[2c9e652]344            callback.new_group(after, after_fields)
[b6c7a44]345
346def 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)
[080bf9a]352    changed_signatories = changed_signatories.order_by('group__name', 'role__display_name', 'person', )
[b6c7a44]353    changed_signatories = changed_signatories.select_related('role', 'group')
354    for callback in callbacks: callback.handle_signatories(changed_signatories)
355
356def generate_diffs():
357    now = datetime.datetime.now()
[a34597e]358    recent = now - datetime.timedelta(hours=24, minutes=15)
[b6c7a44]359    objs = recent_groups(since=recent)
360    callbacks = build_callbacks()
[4091199]361    stats = collections.defaultdict(lambda: 0)
[b6c7a44]362    for callback in callbacks: callback.start_run(since=recent, now=now, )
[4091199]363    diff_objects(objs, since=recent, callbacks=callbacks, stats=stats)
[b6c7a44]364    diff_signatories(recent, now, callbacks=callbacks, )
365    for callback in callbacks: callback.end_run()
366
[4091199]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
[b6c7a44]372if __name__ == '__main__':
373    generate_diffs()
Note: See TracBrowser for help on using the repository browser.