source: asadb/forms/models.py

stablestage
Last change on this file was 86986f5, checked in by Alex Dehnert <adehnert@…>, 12 years ago

People lookup: Handle more name formats (ASA-#253)

  • Split names on commas as well as newlines
  • Parse mailman list info pages -- convert "user at domain" to "user@domain" and ignore parentheses around addresses
  • Property mode set to 100644
File size: 16.6 KB
Line 
1import datetime
2import errno
3import json
4import os
5import re
6
7import ldap
8
9from django.conf import settings
10from django.contrib.auth.models import User
11from django.db import models
12
13import groups.models
14from util.misc import log_and_ignore_failures, mkdir_p
15import util.previews
16
17class FYSM(models.Model):
18    group = models.ForeignKey(groups.models.Group, db_index=True, )
19    display_name = models.CharField(max_length=50, help_text="""Form of your name suitable for display (for example, don't end your name with ", MIT")""")
20    year = models.IntegerField(db_index=True, )
21    website = models.URLField()
22    join_url = models.URLField(verbose_name="recruiting URL", help_text="""<p>If you have a specific web page for recruiting new members of your group, you can link to it here. It will be used as the destination for most links about your group (join link on the main listing page and when clicking on the slide, but not the "website" link on the slide page). If you do not have such a page, use your main website's URL.</p>""")
23    contact_email = models.EmailField(help_text="Give an address for students interested in joining the group to email (e.g., an officers list)")
24    description = models.TextField(help_text="Explain, in no more than 400 characters (including spaces), what your group does and why incoming students should get involved.")
25    logo = models.ImageField(upload_to='fysm/logos', blank=True, help_text="Upload a logo (JPG, GIF, or PNG) to display on the main FYSM page as well as the group detail page. This will be scaled to be 100px wide.")
26    slide = models.ImageField(upload_to='fysm/slides', blank=True, default="", help_text="Upload a slide (JPG, GIF, or PNG) to display on the group detail page. This will be scaled to be at most 600x600 pixels. We recommend making it exactly that size.")
27    tags = models.CharField(max_length=100, blank=True, help_text="Specify some free-form, comma-delimited tags for your group", )
28    categories = models.ManyToManyField('FYSMCategory', blank=True, help_text="Put your group into whichever of our categories seem applicable.", )
29    join_preview = models.ForeignKey('PagePreview', null=True, )
30
31    def save(self, *args, **kwargs):
32        if self.join_preview is None or self.join_url != self.join_preview.url:
33            self.join_preview = PagePreview.allocate_page_preview(
34                filename='fysm/%d/group%d'%(self.year, self.group.pk, ),
35                url=self.join_url,
36            )
37        super(FYSM, self).save(*args, **kwargs) # Call the "real" save() method.
38
39    def __str__(self, ):
40        return "%s (%d)" % (self.display_name, self.year, )
41
42    class Meta:
43        verbose_name = "FYSM submission"
44
45class FYSMCategory(models.Model):
46    name = models.CharField(max_length=25)
47    slug = models.SlugField(unique=True, )
48    blurb = models.TextField()
49
50    def __str__(self, ):
51        return self.name
52
53    class Meta:
54        verbose_name = "FYSM category"
55        verbose_name_plural = "FYSM categories"
56        ordering = ['name', ]
57
58class FYSMView(models.Model):
59    when = models.DateTimeField(default=datetime.datetime.now)
60    fysm = models.ForeignKey(FYSM, null=True, blank=True, )
61    year = models.IntegerField(null=True, blank=True, )
62    page = models.CharField(max_length=20, blank=True, )
63    referer = models.URLField(verify_exists=False, null=True, )
64    user_agent = models.CharField(max_length=255)
65    source_ip = models.IPAddressField()
66    source_user = models.CharField(max_length=30, blank=True, )
67
68    @staticmethod
69    @log_and_ignore_failures(logfile=settings.LOGFILE)
70    def record_metric(request, fysm=None, year=None, page=None, ):
71        record = FYSMView()
72        record.fysm = fysm
73        record.year = year
74        record.page = page
75        if 'HTTP_REFERER' in request.META:
76            record.referer = request.META['HTTP_REFERER']
77        record.user_agent = request.META['HTTP_USER_AGENT']
78        record.source_ip = request.META['REMOTE_ADDR']
79        record.source_user = request.user.username
80        record.save()
81
82class PagePreview(models.Model):
83    update_time = models.DateTimeField(default=datetime.datetime.utcfromtimestamp(0))
84    url = models.URLField()
85    image = models.ImageField(upload_to='page-previews', blank=True, )
86
87    never_updated = datetime.datetime.utcfromtimestamp(0) # Never updated
88    update_interval = datetime.timedelta(hours=23)
89
90    def image_filename(self, ):
91        return os.path.join(settings.MEDIA_ROOT, self.image.name)
92
93
94    @classmethod
95    def allocate_page_preview(cls, filename, url, ):
96        preview = PagePreview()
97        preview.update_time = cls.never_updated
98        preview.url = url
99        preview.image = 'page-previews/%s.jpg' % (filename, )
100        image_filename = preview.image_filename()
101        mkdir_p(os.path.dirname(image_filename))
102        try:
103            os.symlink('no-preview.jpg', image_filename)
104        except OSError as exc:
105            if exc.errno == errno.EEXIST:
106                pass
107            else: raise
108        preview.save()
109        return preview
110
111    def update_preview(self, ):
112        self.update_time = datetime.datetime.now()
113        self.save()
114        failure = util.previews.generate_webpage_preview(self.url, self.image_filename(), )
115        if failure:
116            self.update_time = self.never_updated
117            self.save()
118
119    @classmethod
120    def previews_needing_updates(cls, interval=None, ):
121        if interval is None:
122            interval = cls.update_interval
123        before = datetime.datetime.now() - interval
124        return cls.objects.filter(update_time__lte=before)
125
126    @classmethod
127    def update_outdated_previews(cls, interval=None, ):
128        previews = cls.previews_needing_updates(interval)
129        now = datetime.datetime.now()
130        update_list = []
131        previews_dict = {}
132        for preview in previews:
133            update_list.append((preview.url, preview.image_filename(), ))
134            previews_dict[preview.url] = preview
135            preview.update_time = now
136            preview.save()
137        failures = util.previews.generate_webpage_previews(update_list)
138        for url, msg in failures:
139            print "%s: %s" % (url, msg, )
140            preview = previews_dict[url]
141            preview.update_time = cls.never_updated
142            preview.save()
143
144
145class GroupConfirmationCycle(models.Model):
146    name = models.CharField(max_length=30)
147    slug = models.SlugField(unique=True, )
148    create_date = models.DateTimeField(default=datetime.datetime.now)
149    deadlines = models.TextField(blank=True)
150
151    def __unicode__(self, ):
152        return u"GroupConfirmationCycle %d: %s" % (self.id, self.name, )
153
154    @classmethod
155    def latest(cls, ):
156        return cls.objects.order_by('-create_date')[0]
157
158
159class GroupMembershipUpdate(models.Model):
160    update_time = models.DateTimeField(default=datetime.datetime.utcfromtimestamp(0))
161    updater_name = models.CharField(max_length=30)
162    updater_title = models.CharField(max_length=30, help_text="You need not hold any particular title in the group, but we like to know who is completing the form.")
163   
164    cycle = models.ForeignKey(GroupConfirmationCycle)
165    group = models.ForeignKey(groups.models.Group, help_text="If your group does not appear in the list above, then please email asa-exec@mit.edu.", db_index=True, )
166    group_email = models.EmailField(help_text="The text of the law will be automatically distributed to your members via this list, in order to comply with the law.")
167    officer_email = models.EmailField()
168
169    membership_definition = models.TextField()
170    num_undergrads = models.IntegerField()
171    num_grads = models.IntegerField()
172    num_alum = models.IntegerField()
173    num_other_affiliate = models.IntegerField(verbose_name="Num other MIT affiliates")
174    num_other = models.IntegerField(verbose_name="Num non-MIT")
175
176    membership_list = models.TextField(blank=True, help_text="Member emails on separate lines (Athena usernames where applicable)")
177
178    email_preface = models.TextField(blank=True, help_text="If you would like, you may add text here that will preface the text of the policies when it is sent out to the group membership list provided above.")
179
180    hazing_statement = "By checking this, I hereby affirm that I have read and understand <a href='http://web.mit.edu/asa/rules/ma-hazing-law.html'>Chapter 269: Sections 17, 18, and 19 of Massachusetts Law</a>. I furthermore attest that I have provided the appropriate address or will otherwise distribute to group members, pledges, and/or applicants, copies of Massachusetts Law 269: 17, 18, 19 and that our organization, group, or team agrees to comply with the provisions of that law. (See below for text.)"
181    no_hazing = models.BooleanField(help_text=hazing_statement)
182
183    discrimination_statement = "By checking this, I hereby affirm that I have read and understand the <a href='http://web.mit.edu/referencepubs/nondiscrimination/'>MIT Non-Discrimination Policy</a>.  I furthermore attest that our organization, group, or team agrees to not discriminate against individuals on the basis of race, color, sex, sexual orientation, gender identity, religion, disability, age, genetic information, veteran status, ancestry, or national or ethnic origin."
184    no_discrimination = models.BooleanField(help_text=discrimination_statement)
185
186    def __unicode__(self, ):
187        return "GroupMembershipUpdate for %s" % (self.group, )
188
189
190VALID_UNSET         = 0
191VALID_AUTOVALIDATED = 10
192VALID_OVERRIDDEN    = 20    # confirmed by an admin
193VALID_AUTOREJECTED      = -10
194VALID_HANDREJECTED      = -20
195VALID_CHOICES = (
196    (VALID_UNSET,           "unvalidated"),
197    (VALID_AUTOVALIDATED,   "autovalidated"),
198    (VALID_OVERRIDDEN,      "hand-validated"),
199    (VALID_AUTOREJECTED,    "autorejected"),
200    (VALID_HANDREJECTED,    "hand-rejected"),
201)
202
203class PersonMembershipUpdate(models.Model):
204    update_time = models.DateTimeField(default=datetime.datetime.utcfromtimestamp(0))
205    username = models.CharField(max_length=30)
206    cycle = models.ForeignKey(GroupConfirmationCycle, db_index=True, )
207    deleted = models.DateTimeField(default=None, null=True, blank=True, )
208    valid = models.IntegerField(choices=VALID_CHOICES, default=VALID_UNSET)
209    groups = models.ManyToManyField(groups.models.Group, help_text="By selecting a group here, you indicate that you are an active member of the group in question.<br>If your group does not appear in the list above, then please email asa-exec@mit.edu.<br>")
210
211    def __unicode__(self, ):
212        return "PersonMembershipUpdate for %s" % (self.username, )
213
214
215class PeopleStatusLookup(models.Model):
216    people = models.TextField(help_text="Enter some usernames or email addresses, separated by newlines, to look up here.")
217    requestor = models.ForeignKey(User, null=True, blank=True, )
218    referer = models.URLField(blank=True)
219    time = models.DateTimeField(default=datetime.datetime.now)
220    classified_people_json = models.TextField()
221    _classified_people = None
222
223    def ldap_classify(self, usernames, ):
224        con = ldap.open('ldap-too.mit.edu')
225        con.simple_bind_s("", "")
226        dn = "ou=users,ou=moira,dc=mit,dc=edu"
227        fields = ['uid', 'eduPersonAffiliation', 'mitDirStudentYear']
228
229        chunk_size = 100
230        username_chunks = []
231        ends = range(chunk_size, len(usernames), chunk_size)
232        start = 0
233        end = 0
234        for end in ends:
235            username_chunks.append(usernames[start:end])
236            start = end
237        extra = usernames[end:]
238        if extra:
239            username_chunks.append(extra)
240
241        results = []
242        for chunk in username_chunks:
243            filters = [ldap.filter.filter_format('(uid=%s)', [u]) for u in chunk]
244            userfilter = "(|%s)" % (''.join(filters), )
245            batch_results = con.search_s(dn, ldap.SCOPE_SUBTREE, userfilter, fields)
246            results.extend(batch_results)
247
248        left = set([u.lower() for u in usernames])
249        undergrads = []
250        grads = []
251        staff = []
252        secret = []
253        other = []
254        info = {
255            'undergrads': undergrads,
256            'grads': grads,
257            'staff': staff,
258            'secret': secret,
259            'affiliate': other,
260        }
261        for result in results:
262            username = result[1]['uid'][0]
263            left.remove(username.lower())
264            affiliation = result[1].get('eduPersonAffiliation', ['secret'])[0]
265            if affiliation == 'student':
266                year = result[1].get('mitDirStudentYear', [None])[0]
267                if year == 'G':
268                    grads.append((username, None))
269                elif year.isdigit():
270                    undergrads.append((username, year))
271                else:
272                    other.append((username, year))
273            else:
274                info[affiliation].append((username, None, ))
275        info['unknown'] = [(u, None) for u in left]
276        return info
277
278    def classify_people(self, people):
279        mit_usernames = []
280        alum_addresses = []
281        other_mit_addresses = []
282        nonmit_addresses = []
283
284        for name in people:
285            local, at, domain = name.partition('@')
286            if domain.lower() == 'mit.edu' or domain == '':
287                mit_usernames.append(local)
288            elif domain.lower() == 'alum.mit.edu':
289                alum_addresses.append((name, None))
290            elif domain.endswith('.mit.edu'):
291                other_mit_addresses.append((name, None))
292            else:
293                nonmit_addresses.append((name, None))
294
295        results = self.ldap_classify(mit_usernames)
296        results['alum'] = alum_addresses
297        results['other-mit'] = other_mit_addresses
298        results['non-mit'] = nonmit_addresses
299        return results
300
301    def split_people(self):
302        splitted = re.split(r'[\n,]+', self.people)
303        people = []
304        for name in splitted:
305            name = name.strip()
306            if len(name) > 2 and (name[0] == '(') and (name[-1] == ')'):
307                name = name[1:-1]
308            name = name.replace(' at ', '@')
309            if name:
310                people.append(name)
311        return people
312
313    def update_classified_people(self):
314        people = self.split_people()
315        self._classified_people = self.classify_people(people)
316        self.classified_people_json = json.dumps(self._classified_people)
317        return self._classified_people
318
319    @property
320    def classified_people(self):
321        if self._classified_people is None:
322            self._classified_people = json.loads(self.classified_people_json)
323        return self._classified_people
324
325    def classifications_with_descriptions(self):
326        descriptions = {
327            'undergrads':   'Undergraduate students (class year in parentheses)',
328            'grads':        'Graduate students',
329            'alum':         "Alumni Association addresses",
330            'staff':        'MIT Staff (including faculty)',
331            'affiliate':    'This includes some alumni, group members with Athena accounts sponsored through SAO, and many others.',
332            'secret':       'People with directory information suppressed. These people have Athena accounts, but they could have any MIT affiliation, including just being a student group member.',
333            'unknown':      "While this looks like an Athena account, we couldn't find it. This could be a deactivated account, or it might never have existed.",
334            'other-mit':    ".mit.edu addresses that aren't @mit.edu or @alum.mit.edu.",
335            'non-mit':      "Non-MIT addresses, including outside addresses of MIT students.",
336        }
337
338        names = (
339            ('undergrads', 'Undergrads', ),
340            ('grads', 'Grad students', ),
341            ('alum', 'Alumni', ),
342            ('staff', 'Staff', ),
343            ('affiliate', 'Affiliates', ),
344            ('secret', 'Secret', ),
345            ('unknown', 'Unknown', ),
346            ('other-mit', 'Other MIT addresses', ),
347            ('non-mit', 'Non-MIT addresses', ),
348        )
349
350        classifications = self.classified_people
351        sorted_results = []
352        for k, label in names:
353            sorted_results.append({
354                'label': label,
355                'description': descriptions[k],
356                'people': sorted(classifications[k]),
357            })
358        return sorted_results
359
360
361##########
362# MIDWAY #
363##########
364
365
366class Midway(models.Model):
367    name = models.CharField(max_length=50)
368    slug = models.SlugField()
369    date = models.DateTimeField()
370    table_map = models.ImageField(upload_to='midway/maps')
371
372    def __str__(self, ):
373        return "%s" % (self.name, )
374
375class MidwayAssignment(models.Model):
376    midway = models.ForeignKey(Midway)
377    location = models.CharField(max_length=20)
378    group = models.ForeignKey(groups.models.Group)
379
380    def __str__(self, ):
381        return "<MidwayAssignment: %s at %s at %s>" % (self.group, self.location, self.midway, )
Note: See TracBrowser for help on using the repository browser.