source: asadb/groups/models.py @ 83d82e7

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

Import settings from django.conf (ASA-#126)

For some reason, importing the settings module directly is discouraged, and you
should instead use the settings object from django.conf (see
https://docs.djangoproject.com/en/dev/topics/settings/#using-settings-in-python-code).

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