source: asadb/groups/models.py @ 51b384a

space-accessstablestage
Last change on this file since 51b384a was 51b384a, checked in by Alex Dehnert <adehnert@…>, 13 years ago

Validate constitution_url (ASA-#76)

  • Property mode set to 100644
File size: 20.7 KB
Line 
1from django.db import models
2from django.contrib.auth.models import User
3from django.template.defaultfilters import slugify
4import reversion
5
6import datetime
7import filecmp
8import mimetypes
9import os
10import re
11import shutil
12import urlparse
13import urllib
14import urllib2
15
16import settings
17
18import mit
19
20# Create your models here.
21
22class ActiveGroupManager(models.Manager):
23    def get_query_set(self, ):
24        return super(ActiveGroupManager, self).get_query_set().filter(
25            group_status__slug='active',
26        )
27
28class Group(models.Model):
29    name = models.CharField(max_length=100, db_index=True, )
30    abbreviation = models.CharField(max_length=10, blank=True, db_index=True, )
31    description = models.TextField()
32    activity_category = models.ForeignKey('ActivityCategory', null=True, blank=True, db_index=True, )
33    group_class = models.ForeignKey('GroupClass', db_index=True, )
34    group_status = models.ForeignKey('GroupStatus', db_index=True, )
35    group_funding = models.ForeignKey('GroupFunding', null=True, blank=True, db_index=True, )
36    website_url = models.URLField()
37    constitution_url = models.CharField(max_length=200, blank=True, validators=[mit.UrlOrAfsValidator])
38    meeting_times = models.TextField(blank=True)
39    advisor_name = models.CharField(max_length=100, blank=True)
40    num_undergrads = models.IntegerField(null=True, blank=True, )
41    num_grads = models.IntegerField(null=True, blank=True, )
42    num_community = models.IntegerField(null=True, blank=True, )
43    num_other = models.IntegerField(null=True, blank=True, )
44    group_email = models.EmailField(verbose_name="group email list", blank=True, )
45    officer_email = models.EmailField(verbose_name="officers' email list")
46    main_account_id = models.IntegerField(null=True, blank=True, )
47    funding_account_id = models.IntegerField(null=True, blank=True, )
48    athena_locker = models.CharField(max_length=20, blank=True, help_text="Often, this will appear in your group's URL --- http://web.mit.edu/SOME_VARIANT_OF_GROUP_NAME/, http://www.mit.edu/~SOME_VARIANT_OF_GROUP_NAME/ and http://SOME_VARIANT_OF_GROUP_NAME.scripts.mit.edu/ all correspond to a locker name of SOME_VARIANT_OF_GROUP_NAME. Also, you can probably find this in your entry in the old database.")
49    recognition_date = models.DateTimeField()
50    update_date = models.DateTimeField(editable=False, )
51    updater = models.CharField(max_length=30, editable=False, null=True, ) # match Django username field
52    _updater_set = False
53
54    objects = models.Manager()
55    active_groups = ActiveGroupManager()
56
57    def update_string(self, ):
58        updater = self.updater or "unknown"
59        return "%s by %s" % (self.update_date.strftime(settings.DATETIME_FORMAT_PYTHON), updater, )
60
61    def set_updater(self, who):
62        if hasattr(who, 'username'):
63            self.updater = who.username
64        else:
65            self.updater = who
66        self._updater_set = True
67
68    def save(self, ):
69        if not self._updater_set:
70            self.updater = None
71        self.update_date = datetime.datetime.now()
72        super(Group, self).save()
73
74    def viewable_notes(self, user):
75        return GroupNote.viewable_notes(self, user)
76
77    def officers(self, role=None, person=None, as_of="now",):
78        """Get the set of people holding some office.
79
80        If None is passed for role, person, or as_of, that field will not
81        be constrained. If as_of is "now" (default) the status will be
82        required to be current. If any of the three parameters are set
83        to another value, the corresponding filter will be applied.
84        """
85        office_holders = OfficeHolder.objects.filter(group=self,)
86        if role:
87            if isinstance(role, str):
88                office_holders = office_holders.filter(role__slug=role)
89            else:
90                office_holders = office_holders.filter(role=role)
91        if person:
92            office_holders = office_holders.filter(person=person)
93        if as_of:
94            if as_of == "now": as_of = datetime.datetime.now()
95            office_holders = office_holders.filter(start_time__lte=as_of, end_time__gte=as_of)
96        return office_holders
97
98    def slug(self, ):
99        return slugify(self.name)
100
101    def __str__(self, ):
102        return self.name
103
104    @classmethod
105    def reporting_prefetch(cls, ):
106        return set(['activity_category', 'group_class', 'group_status', 'group_funding'])
107
108    @classmethod
109    def reporting_fields(cls, ):
110        fields = cls._meta.fields
111        return [(f.name, f.verbose_name) for f in fields]
112
113    class Meta:
114        ordering = ('name', )
115        permissions = (
116            ('view_group_private_info', 'View private group information'),
117            # ability to update normal group info or people
118            # this is weaker than change_group, which is the built-in
119            # permission that controls the admin interface
120            ('admin_group', 'Administer basic group information'),
121            ('view_signatories', 'View signatory information for all groups'),
122            ('recognize_nge', 'Recognize Non-Group Entity'),
123            ('recognize_group', 'Recognize groups'),
124        )
125reversion.register(Group)
126
127
128constitution_dir = os.path.join(settings.SITE_ROOT, '..', 'constitutions')
129
130class GroupConstitution(models.Model):
131    group = models.ForeignKey(Group, unique=True, )
132    source_url = models.URLField()
133    dest_file = models.CharField(max_length=100)
134    last_update = models.DateTimeField(help_text='Last time when this constitution actually changed.')
135    last_download = models.DateTimeField(help_text='Last time we downloaded this constitution to see if it had changed.')
136    failure_date = models.DateTimeField(null=True, blank=True, default=None, help_text='Time this URL started failing to download. (Null if currently working.)')
137    status_msg = models.CharField(max_length=20)
138    failure_reason = models.CharField(max_length=100, blank=True, default="")
139
140    def record_failure(self, msg):
141        now = datetime.datetime.now()
142        if not self.failure_date:
143            self.failure_date = now
144        self.status_msg = msg
145        self.failure_reason = self.status_msg
146        self.save()
147
148    def record_success(self, msg, updated):
149        now = datetime.datetime.now()
150        if updated:
151            self.last_update = now
152        self.status_msg = msg
153        self.last_download = now
154        self.failure_date = None
155        self.failure_reason = ""
156        self.save()
157
158    def update(self, ):
159        url = self.source_url
160        success = None
161        old_success = (self.failure_date is None)
162        if url:
163            # Fetch the file
164            error_msg = None
165            try:
166                new_mimetype = None
167                if url.startswith('/afs/') or url.startswith('/mit/'):
168                    new_fp = open(url, 'rb')
169                else:
170                    new_fp = urllib2.urlopen(url)
171                    if new_fp.info().getheader('Content-Type'):
172                        new_mimetype = new_fp.info().gettype()
173
174                new_data = new_fp.read()
175                new_fp.close()
176            except urllib2.HTTPError, e:
177                error_msg = "HTTPError: %s %s" % (e.code, e.msg)
178            except urllib2.URLError, e:
179                error_msg = "URLError: %s" % (e.reason)
180            except IOError:
181                error_msg = "IOError"
182            except ValueError, e:
183                if e.args[0].startswith('unknown url type'):
184                    error_msg = "unknown url type"
185                else:
186                    raise
187            if error_msg:
188                self.record_failure(error_msg)
189                return (False, self.status_msg, old_success, )
190
191            # At this point, failures are our fault, not the group's.
192            # We can let any errors bubble all the way up, rather than
193            # trying to catch and neatly record them
194            success = True
195
196            # Find a destination, and how to put it there
197            old_path = self.path_from_filename(self.dest_file)
198            new_filename = self.compute_filename(url, new_mimetype, )
199
200            # Process the update
201            if new_filename != self.dest_file: # new filename
202                if self.dest_file:
203                    if os.path.exists(old_path):
204                        os.remove(old_path)
205                    else:
206                        print "Warning: %s doesn't exist, but is referenced by dest_file" % (old_path, )
207                self.dest_file = new_filename
208                new_path = self.path_from_filename(new_filename)
209                with open(new_path, 'wb') as fp:
210                    fp.write(new_data)
211                self.record_success("new path", updated=True)
212            else: # old filename
213                with open(old_path, 'rb') as old_fp:
214                    old_data = old_fp.read()
215                if old_data == new_data: # unchanged
216                    self.record_success("no change", updated=False)
217                else: # changed
218                    with open(old_path, 'wb') as fp:
219                        fp.write(new_data)
220                    self.record_success("updated in place", updated=True)
221
222        else:
223            self.record_failure("no url")
224            success = False
225
226        return (success, self.status_msg, old_success, )
227
228    def compute_filename(self, url, mimetype):
229        slug = self.group.slug()
230        known_ext = set([
231            '.pdf',
232            '.ps',
233            '.doc',
234            '.rtf',
235            '.html',
236            '.tex',
237            '.txt'
238        ])
239
240        # This probably breaks on Windows. But that's probably true of
241        # everything...
242        path = urlparse.urlparse(url).path
243        basename, fileext = os.path.splitext(path)
244
245        if fileext:
246            ext = fileext
247        else:
248            if mimetype:
249                extensions = mimetypes.guess_all_extensions(mimetype)
250                for extension in extensions:
251                    if extension in known_ext:
252                        ext = extension
253                        break
254                else:
255                    if len(extensions) > 0:
256                        ext = extensions[0]
257                    else:
258                        ext = ''
259            else:
260                ext = ''
261
262        extmap = {
263            '.htm': '.html',
264            '.php': '.html',
265            '.PS':  '.ps',
266            '.shtml':   '.html',
267            '.text':    '.txt',
268        }
269        # we have no real handling of no extension, .old, and .ksh
270        if ext in extmap: ext = extmap[ext]
271        if ext not in known_ext: ext = ext + '.unknown'
272
273        return "%04d-%s%s" % (self.group.pk, slug, ext, )
274
275    def path_from_filename(self, filename):
276        path = os.path.join(constitution_dir, filename)
277        return path
278
279    def webstat(self, ):
280        url = self.source_url
281        if url:
282            try:
283                stream = urllib.urlopen(self.source_url)
284                return stream.getcode()
285            except IOError:
286                return "IOError"
287        else:
288            return "no-url"
289
290reversion.register(GroupConstitution)
291
292GROUP_STARTUP_STAGE_SUBMITTED = 10
293GROUP_STARTUP_STAGE_APPROVED = 20
294GROUP_STARTUP_STAGE_REJECTED = -10
295GROUP_STARTUP_STAGE = (
296    (GROUP_STARTUP_STAGE_SUBMITTED,     'submitted'),
297    (GROUP_STARTUP_STAGE_APPROVED,      'approved'),
298    (GROUP_STARTUP_STAGE_REJECTED,      'rejected'),
299)
300
301
302class GroupStartup(models.Model):
303    group = models.ForeignKey(Group, unique=True, )
304    stage = models.IntegerField(choices=GROUP_STARTUP_STAGE)
305    submitter = models.CharField(max_length=30, editable=False, )
306    create_officer_list = models.BooleanField()
307    create_group_list = models.BooleanField()
308    create_athena_locker = models.BooleanField()
309    president_name = models.CharField(max_length=50)
310    president_kerberos = models.CharField(max_length=8)
311    treasurer_name = models.CharField(max_length=50)
312    treasurer_kerberos = models.CharField(max_length=8)
313reversion.register(GroupStartup)
314
315
316class GroupNote(models.Model):
317    author = models.CharField(max_length=30, db_index=True, ) # match Django username field
318    timestamp = models.DateTimeField(default=datetime.datetime.now, editable=False, )
319    body = models.TextField()
320    acl_read_group = models.BooleanField(default=True, help_text='Can the group read this note')
321    acl_read_offices = models.BooleanField(default=True, help_text='Can "offices" that interact with groups (SAO, CAC, and funding boards) read this note')
322    group = models.ForeignKey(Group, db_index=True, )
323
324    def __str__(self, ):
325        return "Note by %s on %s" % (self.author, self.timestamp, )
326
327    @classmethod
328    def viewable_notes(cls, group, user):
329        notes = cls.objects.filter(group=group)
330        if not user.has_perm('groups.view_note_all'):
331            q = models.Q(pk=0)
332            if user.has_perm('groups.view_note_group', group):
333                q |= models.Q(acl_read_group=True)
334            if user.has_perm('groups.view_note_office'):
335                q |= models.Q(acl_read_offices=True)
336            notes = notes.filter(q)
337        return notes
338
339    class Meta:
340        permissions = (
341            ('view_note_group',     'View notes intended for the group to see', ),
342            ('view_note_office',    'View notes intended for "offices" to see', ),
343            ('view_note_all',       'View all notes', ),
344        )
345reversion.register(GroupNote)
346
347
348class OfficerRole(models.Model):
349    UNLIMITED = 10000
350
351    display_name = models.CharField(max_length=50)
352    slug = models.SlugField(unique=True, )
353    description = models.TextField()
354    max_count = models.IntegerField(default=UNLIMITED, help_text='Maximum number of holders of this role. Use %d for no limit.' % UNLIMITED)
355    require_student = models.BooleanField(default=False)
356    grant_user = models.ForeignKey(User, null=True, blank=True,
357        limit_choices_to={ 'username__endswith': '@SYSTEM'})
358    publicly_visible = models.BooleanField(default=True, help_text='Can everyone see the holders of this office.')
359
360    def max_count_str(self, ):
361        if self.max_count == self.UNLIMITED:
362            return "unlimited"
363        else:
364            return str(self.max_count)
365
366    def __str__(self, ):
367        return self.display_name
368
369    @classmethod
370    def getGrantUsers(cls, roles):
371        ret = set([role.grant_user for role in roles])
372        if None in ret: ret.remove(None)
373        return ret
374
375    @classmethod
376    def retrieve(cls, slug, ):
377        return cls.objects.get(slug=slug)
378reversion.register(OfficerRole)
379
380
381class OfficeHolder_CurrentManager(models.Manager):
382    def get_query_set(self, ):
383        return super(OfficeHolder_CurrentManager, self).get_query_set().filter(
384            start_time__lte=datetime.datetime.now,
385            end_time__gte=datetime.datetime.now,
386        )
387
388class OfficeHolder(models.Model):
389    EXPIRE_OFFSET   = datetime.timedelta(seconds=1)
390    END_NEVER       = datetime.datetime.max
391
392    person = models.CharField(max_length=30, db_index=True, )
393    role = models.ForeignKey('OfficerRole', db_index=True, )
394    group = models.ForeignKey('Group', db_index=True, )
395    start_time = models.DateTimeField(default=datetime.datetime.now, db_index=True, )
396    end_time = models.DateTimeField(default=datetime.datetime.max, db_index=True, )
397
398    objects = models.Manager()
399    current_holders = OfficeHolder_CurrentManager()
400
401    def format_person(self, ):
402        return AthenaMoiraAccount.try_format_by_username(self.person, )
403
404    def expire(self, ):
405        self.end_time = datetime.datetime.now()-self.EXPIRE_OFFSET
406        self.save()
407
408    def __str__(self, ):
409        return "<OfficeHolder: person=%s, role=%s, group=%s, start_time=%s, end_time=%s>" % (
410            self.person, self.role, self.group, self.start_time, self.end_time, )
411
412    def __repr__(self, ):
413        return str(self)
414reversion.register(OfficeHolder)
415
416
417class PerGroupAuthz:
418    supports_anonymous_user = True
419    supports_inactive_user = True
420    supports_object_permissions = True
421
422    def authenticate(self, username=None, password=None, ):
423        return None # we don't do authn
424    def get_user(user_id, ):
425        return None # we don't do authn
426
427    def has_perm(self, user_obj, perm, obj=None, ):
428        print "Checking user %s for perm %s on obj %s" % (user_obj, perm, obj)
429        if not user_obj.is_active:
430            return False
431        if not user_obj.is_authenticated():
432            return False
433        if obj is None:
434            return False
435        # Great, we're active, authenticated, and not in a recursive call
436        # Check that we've got a reasonable object
437        if getattr(user_obj, 'is_system', False):
438            return False
439        if isinstance(obj, Group):
440            # Having the unqualified perm means that you should have it
441            # on any object
442            if user_obj.has_perm(perm):
443                return True
444            # Now we can do the real work
445            holders = obj.officers(person=user_obj.username).select_related('role__grant_user')
446            sys_users = OfficerRole.getGrantUsers([holder.role for holder in holders])
447            for sys_user in sys_users:
448                sys_user.is_system = True
449                if sys_user.has_perm(perm):
450                    print "While checking user %s for perm %s on obj %s: implicit user %s has perms" % (user_obj, perm, obj, sys_user, )
451                    return True
452        print "While checking user %s for perm %s on obj %s: no perms found (implicit: %s)" % (user_obj, perm, obj, sys_users)
453        return False
454
455
456
457class ActivityCategory(models.Model):
458    name = models.CharField(max_length=50)
459
460    def __str__(self, ):
461        return self.name
462
463    class Meta:
464        verbose_name_plural = "activity categories"
465
466
467class GroupClass(models.Model):
468    name = models.CharField(max_length=50)
469    slug = models.SlugField(unique=True, )
470    description = models.TextField()
471    gets_publicity = models.BooleanField(help_text="Gets publicity resources such as FYSM or Activities Midway")
472
473    def __str__(self, ):
474        return self.name
475
476    class Meta:
477        verbose_name_plural = "group classes"
478
479
480class GroupStatus(models.Model):
481    name = models.CharField(max_length=50)
482    slug = models.SlugField(unique=True, )
483    description = models.TextField()
484    is_active = models.BooleanField(default=True, help_text="This status represents an active group")
485
486    def __str__(self, ):
487        active = ""
488        if not self.is_active:
489            active = " (inactive)"
490        return "%s%s" % (self.name, active, )
491
492    class Meta:
493        verbose_name_plural= "group statuses"
494
495
496class GroupFunding(models.Model):
497    name = models.CharField(max_length=50)
498    slug = models.SlugField(unique=True, )
499    contact_email = models.EmailField()
500    funding_list = models.CharField(max_length=32, blank=True, help_text="List that groups receiving funding emails should be on. The database will attempt to make sure that ONLY those groups are on it.")
501
502    def __str__(self, ):
503        return "%s (%s)" % (self.name, self.contact_email, )
504
505
506class AthenaMoiraAccount_ActiveManager(models.Manager):
507    def get_query_set(self, ):
508        return super(AthenaMoiraAccount_ActiveManager, self).get_query_set().filter(del_date=None)
509
510class AthenaMoiraAccount(models.Model):
511    username = models.CharField(max_length=8, unique=True, )
512    mit_id = models.CharField(max_length=15)
513    first_name      = models.CharField(max_length=45)
514    last_name       = models.CharField(max_length=45)
515    account_class   = models.CharField(max_length=10)
516    mutable         = models.BooleanField(default=True)
517    add_date        = models.DateField(help_text="Date when this person was added to the dump.", )
518    del_date        = models.DateField(help_text="Date when this person was removed from the dump.", blank=True, null=True, )
519    mod_date        = models.DateField(help_text="Date when this person's record was last changed.", blank=True, null=True, )
520
521    objects = models.Manager()
522    active_accounts = AthenaMoiraAccount_ActiveManager()
523
524    def is_student(self, ):
525        # XXX: Is this... right?
526        return self.account_class == 'G' or self.account_class.isdigit()
527
528    def format(self, ):
529        return "%s %s <%s>" % (self.first_name, self.last_name, self.username, )
530
531    def __str__(self, ):
532        if self.mutable:
533            mutable_str = ""
534        else:
535            mutable_str = " (immutable)"
536        return "<AthenaMoiraAccount: username=%s name='%s, %s' account_class=%s%s>" % (
537            self.username, self.last_name, self.first_name,
538            self.account_class, mutable_str,
539        )
540
541    def __repr__(self, ):
542        return str(self)
543
544    @classmethod
545    def try_format_by_username(cls, username):
546        try:
547            moira = AthenaMoiraAccount.objects.get(username=username)
548            return moira.format()
549        except AthenaMoiraAccount.DoesNotExist:
550            return "%s (name not available)" % (username)
551
552    class Meta:
553        verbose_name = "Athena (Moira) account"
Note: See TracBrowser for help on using the repository browser.