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