Index: asadb/space/diffs.py
===================================================================
--- asadb/space/diffs.py	(revision bec776090161b427ed81b09fc5f4a1d499a472d0)
+++ asadb/space/diffs.py	(revision bec776090161b427ed81b09fc5f4a1d499a472d0)
@@ -0,0 +1,171 @@
+#!/usr/bin/python
+import collections
+import datetime
+import os
+import sys
+
+if __name__ == '__main__':
+    cur_file = os.path.abspath(__file__)
+    django_dir = os.path.abspath(os.path.join(os.path.dirname(cur_file), '..'))
+    sys.path.append(django_dir)
+    os.environ['DJANGO_SETTINGS_MODULE'] = 'settings'
+
+from django.core.mail import EmailMessage
+from django.db import connection
+from django.db.models import Q
+from django.template import Context, Template
+from django.template.loader import get_template
+
+import groups.diffs
+import groups.models
+import space.models
+import util.emails
+
+role = {}
+people_name = {} # username -> full name
+people_id = {} # username -> MIT ID
+
+all_spaces = {} # Space.pk -> Space
+
+def fill_people(holder):
+    if not holder.person in people_name:
+        try:
+            person = groups.models.AthenaMoiraAccount.objects.get(username=holder.person)
+            people_name[holder.person] = person.format()
+            people_id[holder.person] = person.mit_id
+        except groups.models.AthenaMoiraAccount.DoesNotExist:
+            people_name[holder.person] = "<%s>" % (holder.person, )
+            people_id[holder.person] = None
+
+class GroupInfo(object):
+    def __init__(self, group, ):
+        self.group = group
+        self.offices = {}  # Space.pk -> (ID -> (Set name, Set name))
+        if not role:
+            role['office'] = groups.models.OfficerRole.objects.get(slug='office-access')
+
+    def learn_access(self, space_pk, old, new):
+        group_pk = self.group.pk
+        if group_pk in old:
+            old_access = old[group_pk]
+        else: old_access = {}
+        if group_pk in new:
+            new_access = new[group_pk]
+        else: new_access = {}
+        assert space_pk not in self.offices
+
+        # Let's fill out the self.offices set.
+        self.offices[space_pk] = collections.defaultdict(lambda: (set(), set()))
+        space_data = self.offices[space_pk]
+        for mit_id, old_set in old_access.items():
+            space_data[mit_id][0].extend(old_set)
+        for mit_id, new_set in new_access.items():
+            space_data[mit_id][1].extend(new_set_set)
+
+    def add_space_signatories(self, old_time, new_time, ):
+        group = self.group
+        old_people = group.officers(as_of=old_time, role=role['office'])
+        new_people = group.officers(as_of=new_time, role=role['office'])
+        for holder in old_people:
+            fill_people(holder)
+        for holder in new_people:
+            fill_people(holder)
+        for office_id, office_data in self.offices.items():
+            for holder in old_people:
+                holder_name = people_name[holder.person]
+                holder_id = people_id[holder.person]
+                office_data[holder_id][0].add(holder_name)
+            for holder in new_people:
+                holder_name = people_name[holder.person]
+                holder_id = people_id[holder.person]
+                office_data[holder_id][1].add(holder_name)
+
+    def list_changes(self, ):
+        cac_lines = []
+        group_lines = []
+        def append_change(mit_id, verb, name):
+            cac_lines.append("%s:\t%s:\t%s" % (mit_id, verb, name))
+            group_lines.append("%s:\t%s" % (verb, name))
+        changes = False
+        for space_pk, space_data in self.offices.items():
+            line = "Changes in %s:" % (all_spaces[space_pk].number, )
+            cac_lines.append(line)
+            group_lines.append(line)
+            for mit_id, (old_names, new_names) in space_data.items():
+                if mit_id is None: mit_id = "ID unknown"
+                if old_names == new_names:
+                    pass
+                else:
+                    changes = True
+                    for name in old_names:
+                        if name in new_names:
+                            append_change(mit_id, "Keep", name)
+                        else:
+                            append_change(mit_id, "Remove", name)
+                    for name in new_names:
+                        if name in old_names:
+                            pass
+                        else:
+                            append_change(mit_id, "Add", name)
+            cac_lines.append("")
+            group_lines.append("")
+
+        cac_msg = "\n".join(cac_lines)
+        group_msg = "\n".join(group_lines)
+        return changes, cac_msg, group_msg
+
+def init_groups(the_groups, assignments):
+    for assignment in assignments:
+        group = assignment.group
+        if group.id not in the_groups:
+            the_groups[group.id] = GroupInfo(group)
+
+def space_specific_access(group_data, old_time, new_time, ):
+    process_spaces =  space.models.Space.objects.all()
+    #process_spaces = process_spaces.filter(number="W20-467")
+    for the_space in process_spaces:
+        old_data = the_space.build_access(time=old_time)
+        new_data = the_space.build_access(time=new_time)
+        all_spaces[the_space.pk] = the_space
+        init_groups(group_data, old_data[1])
+        init_groups(group_data, new_data[1])
+        for group_pk, group in group_data.items():
+            if group_pk in old_data[0] or group_pk in new_data[0]:
+                group.learn_access(the_space.pk, old_data[0], new_data[0])
+
+
+def space_access_diffs():
+    new_time = datetime.datetime.utcnow()
+    old_time = new_time - datetime.timedelta(days=1)
+    group_data = {} # Group.pk -> GroupInfo
+    changed_groups = []
+    space_specific_access(group_data, old_time, new_time)
+    for group_pk, group_info in group_data.items():
+        group_info.add_space_signatories(old_time, new_time)
+        changes, cac_changes, group_changes = group_info.list_changes()
+        if changes:
+            changed_groups.append((group_info.group, cac_changes, group_changes))
+
+    asa_rcpts = ['asa-space@mit.edu', 'asa-db@mit.edu', ]
+    util.emails.email_from_template(
+        tmpl='space/cac-change-email.txt',
+        context={'changed_groups': changed_groups},
+        subject="Office access updates",
+        to=['caclocks@mit.edu'],
+        cc=asa_rcpts,
+    ).send()
+    for group, cac_msg, group_msg in changed_groups:
+        util.emails.email_from_template(
+            tmpl='space/group-change-email.txt',
+            context={
+                'group':group,
+                'msg':group_msg,
+            },
+            subject="Office access updates",
+            to=[group.officer_email],
+            cc=asa_rcpts,
+        ).send()
+        
+
+if __name__ == "__main__":
+    space_access_diffs()
