class ActivityComponentMark(models.Model): """ Marking of one particular component of an activity for one student Stores the mark the student gets for the component """ activity_mark = models.ForeignKey(ActivityMark, null=False, on_delete=models.PROTECT) activity_component = models.ForeignKey(ActivityComponent, null=False, on_delete=models.PROTECT) value = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='Mark', null=True, blank=True) comment = models.TextField(null = True, max_length=1000, blank=True) config = JSONField(null=False, blank=False, default=dict) # 'display_raw': Whether the comment should be displayed in a <pre> tag instead of using the # linebreaks filter. Useful for comments with blocks of code. defaults = {'display_raw': False} display_raw, set_display_raw = getter_setter('display_raw') def __str__(self): # get the student and the activity return "Marking for [%s]" %(self.activity_component,) def delete(self, *args, **kwargs): raise NotImplementedError("This object cannot be deleted because it is used for marking history") class Meta: unique_together = (('activity_mark', 'activity_component'),) ordering = ('activity_component',)
class FormGroupMember(models.Model): """ Member of a FormGroup. Upgraded for simple ManyToManyField so we have the .config Do not use as a foreign key: is deleted when people leave the FormGroup """ person = models.ForeignKey(Person) formgroup = models.ForeignKey(FormGroup) config = JSONField(null=False, blank=False, default={}) # addition configuration stuff: # 'email': should this member receive emails on completed sheets? defaults = {'email': True} email, set_email = getter_setter('email') class Meta: db_table = 'onlineforms_formgroup_members' # to make it Just Work with the FormGroup.members without "through=" that existed previously unique_together = (("person", "formgroup"), ) def __unicode__(self): return u"%s in %s" % (self.person.name(), self.formgroup.name)
class Page(models.Model): """ A page in this courses "web site". Actual data is versioned in PageVersion objects. """ offering = models.ForeignKey(CourseOffering) label = models.CharField(max_length=30, db_index=True, help_text="The “filename” for this page") can_read = models.CharField(max_length=4, choices=READ_ACL_CHOICES, default="ALL", help_text="Who should be able to view this page?") can_write = models.CharField(max_length=4, choices=WRITE_ACL_CHOICES, default="STAF", verbose_name="Can change", help_text="Who should be able to edit this page?") config = JSONField(null=False, blank=False, default={}) # addition configuration stuff: # p.config['releasedate']: date after which is page is visible # p.config['editdate']: date after which is page is editable # p.config['migrated_to']: if this page was migrated to a new location, the new (offering.slug, page.label) # p.config['migrated_from']: if this page was migrated from an old location, the old (offering.slug, page.label) # p.config['prevent_redirect']: if True, don't do a redirect, even if migration settings look like it should. defaults = {'releasedate': None, 'editdate': None} releasedate_txt, set_releasedate_txt = getter_setter('releasedate') editdate_txt, set_editdate_txt = getter_setter('editdate') class Meta: ordering = ['label'] unique_together = (('offering', 'label'), ) def save(self, *args, **kwargs): assert self.label_okay(self.label) is None self.expire_offering_cache() super(Page, self).save(*args, **kwargs) def releasedate(self): d = self.releasedate_txt() if d is None: return None else: return datetime.datetime.strptime(d, "%Y-%m-%d").date() def editdate(self): d = self.editdate_txt() if d is None: return None else: return datetime.datetime.strptime(d, "%Y-%m-%d").date() def set_releasedate(self, val): if isinstance(val, datetime.date): val = val.strftime("%Y-%m-%d") self.set_releasedate_txt(val) def set_editdate(self, val): if isinstance(val, datetime.date): val = val.strftime("%Y-%m-%d") self.set_editdate_txt(val) def get_absolute_url(self): if self.label == 'Index': return reverse('offering:pages:index_page', kwargs={'course_slug': self.offering.slug}) else: return reverse('offering:pages:view_page', kwargs={'course_slug': self.offering.slug, 'page_label': self.label}) def version_cache_key(self): return "page-curver-" + str(self.id) def macro_cache_key(self): return "MACROS-" + str(self.offering_id) def expire_offering_cache(self): # invalidate cache for all pages in this offering: makes sure current page, and all <<filelist>> are up to date for pv in PageVersion.objects.filter(page__offering=self.offering): cache.delete(pv.html_cache_key()) cache.delete(pv.wikitext_cache_key()) # other cache cleanup cache.delete(self.version_cache_key()) cache.delete(self.macro_cache_key()) def label_okay(self, label): """ Check to make sure this label is acceptable (okay characters) Used by both self.save() and model validator. """ m = label_re.match(label) if not m: return "Labels can contain only letters, numbers, underscores, dashes, and periods." def __unicode__(self): return self.offering.name() + '/' + self.label def current_version(self): """ The most recent PageVersion object for this Page Cached to save the frequent lookup. """ key = self.version_cache_key() v = cache.get(key) if v: return v else: v = PageVersion.objects.filter(page=self).select_related('editor__person').latest('created_at') cache.set(key, v, 24*3600) # expired when a PageVersion is saved return v def XXX_safely_delete(self): """ Delete this page (and by "delete", we mean "don't really delete"). No longer used since redirects are now left in place of deleted pages. """ with transaction.atomic(): # mangle name and short-name so instructors can delete and replace i = 1 while True: suffix = "__%04i" % (i) existing = Page.objects.filter(offering=self.offering, label=self.label+suffix).count() if existing == 0: break i += 1 new_label = self.label+suffix self.label = new_label self.can_read = 'NONE' self.can_write = 'NONE' self.save() @staticmethod def adjust_acl_release(acl_value, date): """ Adjust the access control value appropriately, taking the release date into account. """ if not date: # no release date, so nothing to do. return acl_value elif date and datetime.date.today() >= date: # release date passed: nothing to do. return acl_value else: # release date hasn't passed: upgrade the security level accordingly. if acl_value == 'NONE': return 'NONE' elif acl_value == 'STAF': return 'INST' else: return 'STAF' def release_message(self): return self._release_message(self.releasedate(), self.can_read, "viewable") def _release_message(self, date, acl_value, attrib): today = datetime.date.today() if not date: return None elif date > today: return "This page has not yet been released. It will be %s by %s as of %s." % (attrib, ACL_DESC[acl_value], date) else: #return "This page was made %s automatically on %s." % (attrib, date) return None
class PageVersion(models.Model): """ A particular revision of a Page's contents. Could be either a wiki page or a file attachment. """ page = models.ForeignKey(Page, blank=True, null=True) title = models.CharField(max_length=60, help_text="The title for the page") wikitext = models.TextField(help_text='WikiCreole-formatted content of the page') diff = models.TextField(null=True, blank=True) diff_from = models.ForeignKey('PageVersion', null=True) file_attachment = models.FileField(storage=PageFilesStorage, null=False, upload_to=attachment_upload_to, blank=False, max_length=500) file_mediatype = models.CharField(null=False, blank=False, max_length=200) file_name = models.CharField(null=False, blank=False, max_length=200) redirect = models.CharField(null=True, blank=True, max_length=500) # URL to redirect to: may be an absolute URL or relative from the location of self.page created_at = models.DateTimeField(auto_now_add=True) editor = models.ForeignKey(Member) comment = models.TextField() config = JSONField(null=False, blank=False, default={}) # addition configuration stuff: # p.config['math']: page uses MathJax? (boolean) # p.config['syntax']: page uses SyntaxHighlighter? (boolean) # p.config['brushes']: used SyntaxHighlighter brushes (list of strings) # p.config['depth']: max depth of diff pages below me (to keep it within reason) # p.config['redirect_reason']: if present, how this redirect got here: 'rename' or 'delete'. defaults = {'math': False, 'syntax': False, 'brushes': [], 'depth': 0, 'redirect_reason': None} math, set_math = getter_setter('math') syntax, set_syntax = getter_setter('syntax') brushes, set_brushes = getter_setter('brushes') depth, set_depth = getter_setter('depth') redirect_reason, set_redirect_reason = getter_setter('redirect_reason') def html_cache_key(self): return "page-html-" + str(self.id) def wikitext_cache_key(self): return "page-wikitext-" + str(self.id) def get_wikitext(self): """ Return this version's wikitext (reconstructing from diffs if necessary). Caches when reconstructing from diffs """ if self.diff_from: key = self.wikitext_cache_key() wikitext = cache.get(key) if wikitext: return unicode(wikitext) else: src = self.diff_from diff = json.loads(self.diff) wikitext = src.apply_changes(diff) cache.set(key, wikitext, 24*3600) # no need to expire: shouldn't change for a version return unicode(wikitext) return unicode(self.wikitext) def __init__(self, *args, **kwargs): super(PageVersion, self).__init__(*args, **kwargs) self.Creole = None def get_creole(self, offering=None): if not self.Creole: if offering: self.Creole = ParserFor(offering, self) else: self.Creole = ParserFor(self.page.offering, self) def previous_version(self): """ Return the version before this one, or None """ try: prev = PageVersion.objects.filter(page=self.page, created_at__lt=self.created_at).latest('created_at') return prev except PageVersion.DoesNotExist: return None def changes(self, other): """ Changes to get from the get_wikitext() of self to other. List of changes that can be insertions, deletions, or replacements. Each is a tuple containing: (type flag, position of change, [other info need to reconstrut original]) """ lines1 = self.get_wikitext().split("\n") lines2 = other.get_wikitext().split("\n") matcher = difflib.SequenceMatcher() matcher.set_seqs(lines1, lines2) changes = [] for tag, i1, i2, j1, j2 in matcher.get_opcodes(): if tag == 'equal': # ignore no-change blocks pass elif tag == 'insert': changes.append(("I", i1, lines2[j1:j2])) elif tag == 'delete': changes.append(("D", i1, i2)) elif tag == 'replace': changes.append(("R", i1, i2, lines2[j1:j2])) else: raise ValueError return changes def apply_changes(self, changes): """ Apply changes to this wikitext """ lines = self.get_wikitext().split("\n") # sort by reverse linenumber: make sure we make changes in the right place changes.sort(cmp=lambda x,y: cmp(y[1], x[1])) for change in changes: c = change[0] if c=="I": _, pos, ls = change lines[pos:pos] = ls elif c=="D": _, pos1, pos2 = change del lines[pos1:pos2] elif c=="R": _, pos1, pos2, ls = change lines[pos1:pos2] = ls else: raise ValueError return u"\n".join(lines) def diff_to(self, other): """ Turn this version into a diff based on the other version (if apprpriate). """ if not self.wikitext or self.diff_from: # must already be a diff: don't repeat ourselves return if self.depth() > 10: # don't let the chain of diffs get too long return oldw = self.wikitext diff = json.dumps(other.changes(self), separators=(',',':')) if len(diff) > len(oldw): # if it's a big change, don't bother. return self.diff = diff self.diff_from = other self.wikitext = '' self.save(check_diff=False) # save but don't go back for more diffing other.set_depth(max(self.depth()+1, other.depth())) other.save(minor_change=True) neww = self.get_wikitext() assert oldw==neww def save(self, check_diff=True, minor_change=False, *args, **kwargs): # check coherence of the data model: exactly one of full text, diff text, file, redirect. if not minor_change: # minor_change flag set when .diff_to has changed the .config only has_wikitext = bool(self.wikitext) has_difffrom = bool(self.diff_from) has_diff = bool(self.diff) has_file = bool(self.file_attachment) has_redirect = bool(self.redirect) assert (has_wikitext and not has_difffrom and not has_diff and not has_file and not has_redirect) \ or (not has_wikitext and has_difffrom and has_diff and not has_file and not has_redirect) \ or (not has_wikitext and not has_difffrom and not has_diff and has_file and not has_redirect) \ or (not has_wikitext and not has_difffrom and not has_diff and not has_file and has_redirect) # normalize newlines so our diffs are consistent later self.wikitext = normalize_newlines(self.wikitext) # set the SyntaxHighlighter brushes used on this page. self.set_brushes([]) wikitext = self.get_wikitext() if wikitext: self.get_creole() brushes = brushes_used(self.Creole.parser.parse(wikitext)) self.set_brushes(list(brushes)) self.set_syntax(bool(brushes)) super(PageVersion, self).save(*args, **kwargs) # update the *previous* PageVersion so it's a diff instead of storing full text if check_diff and not minor_change: prev = self.previous_version() if prev: prev.diff_to(self) self.page.expire_offering_cache() def __unicode__(self): return unicode(self.page) + '@' + unicode(self.created_at) def is_filepage(self): """ Is this PageVersion a file attachment (as opposed to a Wiki page)? """ return bool(self.file_attachment) @staticmethod def _offering_macros(offering): """ Do the actual work of constructing the macro dict for this offering """ try: pv = PageVersion.objects.filter(page__offering=offering, page__label=MACRO_LABEL).latest('created_at') except PageVersion.DoesNotExist: return {} macros = {} for line in pv.get_wikitext().splitlines(True): m = macroline_re.match(line) if m: macros[m.group('key')] = m.group('value') return macros def offering_macros(self): """ Return a dict of macros for this page's offering (caches _offering_macros). """ if not self.page: return {} offering = self.Creole.offering key = self.page.macro_cache_key() macros = cache.get(key) if macros is not None: return macros else: macros = PageVersion._offering_macros(offering) cache.set(key, macros, 24*3600) # expired when a page is saved return macros def substitute_macros(self, wikitext): """ Substitute our macros into the wikitext. """ macros = self.offering_macros() if macros: for macro, replacement in macros.iteritems(): wikitext = wikitext.replace('+' + macro + '+', replacement) return wikitext def html_contents(self, offering=None): """ Return the HTML version of this version's wikitext (with macros substituted if available) offering argument only required if self.page isn't set: used when doing a speculative conversion of unsaved content. Cached to save frequent conversion. """ key = self.html_cache_key() html = cache.get(key) if html: return mark_safe(html) else: self.get_creole(offering=offering) wikitext = self.get_wikitext() html = self.Creole.text2html(self.substitute_macros(wikitext)) cache.set(key, html, 24*3600) # expired if activities are changed (in signal below), or by saving a PageVersion in this offering return mark_safe(html)
class SheetSubmission(models.Model): form_submission = models.ForeignKey(FormSubmission) sheet = models.ForeignKey(Sheet) filler = models.ForeignKey(FormFiller) status = models.CharField(max_length=4, choices=SUBMISSION_STATUS, default="WAIT") given_at = models.DateTimeField(auto_now_add=True) completed_at = models.DateTimeField(null=True) # key = models.CharField() def autoslug(self): return self.filler.identifier() slug = AutoSlugField(populate_from=autoslug, null=False, editable=False, unique_with='form_submission') config = JSONField(null=False, blank=False, default={}) # addition configuration stuff: # 'assigner': the user who assigned this sheet to the filler (Person.id value) # 'assign_note': optional note provided for asignee when sheet was assigned by admin # 'assign_comment': optional comment provided by admin about the formsubmission # 'reject_reason': reason given for rejecting the sheet # 'return_reason': reason given for returning the sheet to the filler def save(self, *args, **kwargs): with django.db.transaction.atomic(): self.completed_at = datetime.datetime.now() super(SheetSubmission, self).save(*args, **kwargs) self.form_submission.update_status() def __unicode__(self): return u"%s by %s" % (self.sheet, self.filler.identifier()) defaults = { 'assigner': None, 'assign_comment': None, 'assign_note': None, 'reject_reason': None, 'return_reason': None } assigner_id, set_assigner_id = getter_setter('assigner') assign_note, set_assign_note = getter_setter('assign_note') assign_comment, set_assign_comment = getter_setter('assign_comment') reject_reason, set_reject_reason = getter_setter('reject_reason') return_reason, set_return_reason = getter_setter('return_reason') cached_fields = None def get_field_submissions(self, refetch=False): if refetch or not (self.cached_fields): self.cached_fields = FieldSubmission.objects.filter( sheet_submission=self) return self.cached_fields field_submissions = property(get_field_submissions) def assigner(self): assigner_id = self.assigner_id() if assigner_id: return Person.objects.get(id=assigner_id) else: return None def set_assigner(self, assigner): self.set_assigner_id(assigner.id) def get_secret(self): try: return SheetSubmissionSecretUrl.objects.get(sheet_submission=self) except SheetSubmissionSecretUrl.DoesNotExist: return None def get_submission_url(self): """ Creates a URL for a sheet submission. If a secret URL has been generated it will use that, otherwise it will create a standard URL. """ secret_urls = SheetSubmissionSecretUrl.objects.filter( sheet_submission=self) if secret_urls: return reverse('onlineforms.views.sheet_submission_via_url', kwargs={'secret_url': secret_urls[0].key}) else: return reverse('onlineforms.views.sheet_submission_subsequent', kwargs={ 'form_slug': self.form_submission.form.slug, 'formsubmit_slug': self.form_submission.slug, 'sheet_slug': self.sheet.slug, 'sheetsubmit_slug': self.slug }) @classmethod def sheet_maintenance(cls): """ Do all of the stuff we need to update on a regular basis. """ cls.reject_dormant_initial() cls.email_waiting_sheets() @classmethod def reject_dormant_initial(cls): """ Close any initial sheets that have been hanging around for too long. """ days = 14 min_age = datetime.datetime.now() - datetime.timedelta(days=days) sheetsubs = SheetSubmission.objects.filter(sheet__is_initial=True, status='WAIT', given_at__lt=min_age) for ss in sheetsubs: ss.status = 'REJE' ss.set_reject_reason( 'Automatically closed by system after being dormant %i days.' % (days)) ss.save() fs = ss.form_submission fs.status = 'DONE' fs.set_summary( 'Automatically closed by system after being dormant %i days.' % (days)) fs.save() @classmethod def waiting_sheets_by_user(cls): min_age = datetime.datetime.now() - datetime.timedelta(hours=24) sheet_subs = SheetSubmission.objects.exclude(status='DONE').exclude(status='REJE') \ .exclude(given_at__gt=min_age) \ .order_by('filler__id') \ .select_related('filler__sfuFormFiller', 'filler__nonSFUFormFiller', 'form_submission__form__initiator', 'sheet') return itertools.groupby(sheet_subs, lambda ss: ss.filler) @classmethod def email_waiting_sheets(cls): """ Email those with sheets waiting for their attention. """ full_url = settings.BASE_ABS_URL + reverse('onlineforms.views.index') subject = 'Waiting form reminder' from_email = "*****@*****.**" filler_ss = cls.waiting_sheets_by_user() template = get_template('onlineforms/emails/reminder.txt') for filler, sheets in filler_ss: # annotate with secret URL, so we can remind of that. sheets = list(sheets) for s in sheets: secrets = SheetSubmissionSecretUrl.objects.filter( sheet_submission=s) if secrets: s.secret = secrets[0] else: s.secret = None context = Context({ 'full_url': full_url, 'filler': filler, 'sheets': list(sheets), 'BASE_ABS_URL': settings.BASE_ABS_URL }) msg = EmailMultiAlternatives( subject, template.render(context), from_email, [filler.email()], headers={'X-coursys-topic': 'onlineforms'}) msg.send() def _send_email(self, request, template_name, subject, mail_from, mail_to, context): """ Send email to user as required in various places below. """ plaintext = get_template('onlineforms/emails/' + template_name + '.txt') html = get_template('onlineforms/emails/' + template_name + '.html') sheeturl = request.build_absolute_uri(self.get_submission_url()) context['sheeturl'] = sheeturl email_context = Context(context) msg = EmailMultiAlternatives( subject, plaintext.render(email_context), mail_from, mail_to, headers={'X-coursys-topic': 'onlineforms'}) msg.attach_alternative(html.render(email_context), "text/html") msg.send() def email_assigned(self, request, admin, assignee): full_url = request.build_absolute_uri(self.get_submission_url()) context = { 'username': admin.name(), 'assignee': assignee.name(), 'sheeturl': full_url, 'sheetsub': self } subject = 'CourSys: You have been assigned a sheet.' self._send_email(request, 'sheet_assigned', subject, FormFiller.form_full_email(admin), [assignee.full_email()], context) def email_started(self, request): full_url = request.build_absolute_uri(self.get_submission_url()) context = { 'initiator': self.filler.name(), 'sheeturl': full_url, 'sheetsub': self } subject = u'%s submission incomplete' % (self.sheet.form.title) self._send_email(request, 'nonsfu_sheet_started', subject, settings.DEFAULT_FROM_EMAIL, [self.filler.full_email()], context) def email_submitted(self, request, rejected=False): full_url = request.build_absolute_uri( reverse('onlineforms.views.view_submission', kwargs={ 'form_slug': self.sheet.form.slug, 'formsubmit_slug': self.form_submission.slug })) context = { 'initiator': self.filler.name(), 'adminurl': full_url, 'form': self.sheet.form, 'rejected': rejected } subject = u'%s submission' % (self.sheet.form.title) self._send_email(request, 'sheet_submitted', subject, settings.DEFAULT_FROM_EMAIL, self.sheet.form.owner.notify_emails(), context) def email_returned(self, request, admin): context = {'admin': admin, 'sheetsub': self} self._send_email(request, 'sheet_returned', u'%s submission returned' % (self.sheet.title), FormFiller.form_full_email(admin), [self.filler.full_email()], context)
class FormSubmission(models.Model): form = models.ForeignKey(Form) initiator = models.ForeignKey(FormFiller) owner = models.ForeignKey(FormGroup) status = models.CharField(max_length=4, choices=FORM_SUBMISSION_STATUS, default="PEND") def autoslug(self): return self.initiator.identifier() slug = AutoSlugField(populate_from=autoslug, null=False, editable=False, unique_with='form') config = JSONField(null=False, blank=False, default={}) # addition configuration stuff: # 'summary': summary of the form entered when closing it # 'emailed': True if the initiator was emailed when the form was closed # 'closer': coredata.Person.id of the person that marked the formsub as DONE defaults = {'summary': '', 'emailed': False, 'closer': None} summary, set_summary = getter_setter('summary') emailed, set_emailed = getter_setter('emailed') closer_id, set_closer = getter_setter('closer') def update_status(self): sheet_submissions = SheetSubmission.objects.filter( form_submission=self) if all(sheet_sub.status in ['DONE', 'REJE'] for sheet_sub in sheet_submissions): self.status = 'PEND' else: self.status = 'WAIT' self.save() def __unicode__(self): return u"%s for %s" % (self.form, self.initiator) def get_absolute_url(self): return reverse('onlineforms.views.view_submission', kwargs={ 'form_slug': self.form.slug, 'formsubmit_slug': self.slug }) def closer(self): try: return Person.objects.get(id=self.closer_id()) except Person.DoesNotExist: return None def last_sheet_completion(self): return self.sheetsubmission_set.all().aggregate( Max('completed_at'))['completed_at__max'] def email_notify_completed(self, request, admin): plaintext = get_template('onlineforms/emails/notify_completed.txt') html = get_template('onlineforms/emails/notify_completed.html') email_context = Context({'formsub': self, 'admin': admin}) subject = '%s submission complete' % (self.form.title) from_email = FormFiller.form_full_email(admin) to = self.initiator.full_email() msg = EmailMultiAlternatives( subject=subject, body=plaintext.render(email_context), from_email=from_email, to=[to], bcc=[admin.full_email()], headers={'X-coursys-topic': 'onlineforms'}) msg.attach_alternative(html.render(email_context), "text/html") msg.send() def email_notify_new_owner(self, request, admin): plaintext = get_template('onlineforms/emails/notify_new_owner.txt') html = get_template('onlineforms/emails/notify_new_owner.html') full_url = request.build_absolute_uri( reverse('onlineforms.views.view_submission', kwargs={ 'form_slug': self.form.slug, 'formsubmit_slug': self.slug })) email_context = Context({ 'formsub': self, 'admin': admin, 'adminurl': full_url }) subject = u'%s submission transferred' % (self.form.title) from_email = FormFiller.form_full_email(admin) to = self.owner.notify_emails() msg = EmailMultiAlternatives( subject=subject, body=plaintext.render(email_context), from_email=from_email, to=to, bcc=[admin.full_email()], headers={'X-coursys-topic': 'onlineforms'}) msg.attach_alternative(html.render(email_context), "text/html") msg.send()
class Form(models.Model, _FormCoherenceMixin): title = models.CharField(max_length=60, null=False, blank=False, help_text='The name of this form.') owner = models.ForeignKey( FormGroup, help_text='The group of users who own/administrate this form.') description = models.CharField( max_length=500, null=False, blank=False, help_text= 'A brief description of the form that can be displayed to users.') initiators = models.CharField( max_length=3, choices=INITIATOR_CHOICES, default="NON", help_text= 'Who is allowed to fill out the initial sheet? That is, who can initiate a new instance of this form?' ) unit = models.ForeignKey(Unit) active = models.BooleanField(default=True) original = models.ForeignKey('self', null=True, blank=True) created_date = models.DateTimeField(auto_now_add=True) last_modified = models.DateTimeField(auto_now=True) advisor_visible = models.BooleanField( default=False, help_text="Should submissions be visible to advisors in this unit?") def autoslug(self): return make_slug(self.unit.label + ' ' + self.title) slug = AutoSlugField(populate_from=autoslug, null=False, editable=False, unique=True) config = JSONField(null=False, blank=False, default={}) # addition configuration stuff: # 'loginprompt': should the "log in with your account" prompt be displayed for non-logged-in? (default True) defaults = {'loginprompt': True} loginprompt, set_loginprompt = getter_setter('loginprompt') def __unicode__(self): return u"%s [%s]" % (self.title, self.id) def delete(self, *args, **kwargs): self.active = False self.save() def save(self, *args, **kwargs): with django.db.transaction.atomic(): instance = super(Form, self).save(*args, **kwargs) self.cleanup_fields() return instance @property def initial_sheet(self): sheets = Sheet.objects.filter(form=self, active=True, is_initial=True) if len(sheets) > 0: return sheets[0] else: return None cached_sheets = None def get_sheets(self, refetch=False): if refetch or not (self.cached_sheets): self.cached_sheets = Sheet.objects.filter( form=self, active=True).order_by('order') return self.cached_sheets sheets = property(get_sheets) def get_initiators_display_short(self): return INITIATOR_SHORT[self.initiators] def duplicate(self): """ Make a independent duplicate of this form. Not called from the UI anywhere, but can be used to duplicate a form for another unit, without the pain of re-creating everything: newform = oldform.duplicate() newform.owner = ... newform.unit = ... newform.initiators = ... newform.slug = None newform.save() """ with django.db.transaction.atomic(): newform = self.clone() newform.original = None newform.slug = None newform.active = True newform.initiators = 'NON' newform.save() sheets = Sheet.objects.filter(form=self) for s in sheets: newsheet = s.clone() newsheet.form = newform newsheet.original = None newsheet.slug = None newsheet.save() fields = Field.objects.filter(sheet=s) for f in fields: newfield = f.clone() newfield.sheet = newsheet newfield.original = None newfield.slug = None newfield.save() return newform def all_submission_summary(self): """ Generate summary data of each submission for CSV output """ DATETIME_FMT = "%Y-%m-%d" headers = [] data = [] # find all sheets (in a sensible order: deleted last) sheets = Sheet.objects.filter( form__original_id=self.original_id).order_by( 'order', '-created_date') active_sheets = [s for s in sheets if s.active] inactive_sheets = [s for s in sheets if not s.active] sheet_info = collections.OrderedDict() for s in itertools.chain(active_sheets, inactive_sheets): if s.original_id not in sheet_info: sheet_info[s.original_id] = { 'title': s.title, 'fields': collections.OrderedDict(), 'is_initial': s.is_initial, } # find all fields in each of those sheets (in a equally-sensible order) fields = Field.objects.filter( sheet__form__original_id=self.original_id).select_related( 'sheet').order_by('order', '-created_date') active_fields = [f for f in fields if f.active] inactive_fields = [f for f in fields if not f.active] for f in itertools.chain(active_fields, inactive_fields): if not FIELD_TYPE_MODELS[f.fieldtype].in_summary: continue info = sheet_info[f.sheet.original_id] if f.original_id not in info['fields']: info['fields'][f.original_id] = { 'label': f.label, } # build header row for sid, info in sheet_info.iteritems(): headers.append(info['title'].upper()) headers.append(None) if info['is_initial']: headers.append('Initiated') for fid, finfo in info['fields'].iteritems(): headers.append(finfo['label']) headers.append('Last Sheet Completed') headers.append('Link') # go through FormSubmissions and create a row for each formsubs = FormSubmission.objects.filter(form__original_id=self.original_id, status='DONE') \ .select_related('initiator__sfuFormFiller', 'initiator__nonSFUFormFiller', 'form') # selecting only fully completed forms: does it make sense to be more liberal and report status? # choose a winning SheetSubmission: there may be multiples of each sheet but we're only outputting one sheetsubs = SheetSubmission.objects.filter(form_submission__form__original_id=self.original_id, status='DONE') \ .order_by('given_at').select_related('sheet', 'filler__sfuFormFiller', 'filler__nonSFUFormFiller') # Docs for the dict constructor: "If a key occurs more than once, the last value for that key becomes the corresponding value in the new dictionary." # Result is that the sheetsub with most recent given_at wins. winning_sheetsub = dict( ((ss.form_submission_id, ss.sheet.original_id), ss) for ss in sheetsubs) # collect fieldsubs to output fieldsubs = FieldSubmission.objects.filter(sheet_submission__form_submission__form__original_id=self.original_id) \ .order_by('sheet_submission__given_at') \ .select_related('sheet_submission', 'field') fieldsub_lookup = dict( ((fs.sheet_submission_id, fs.field.original_id), fs) for fs in fieldsubs) for formsub in formsubs: row = [] found_anything = False last_completed = None for sid, info in sheet_info.iteritems(): if (formsub.id, sid) in winning_sheetsub: ss = winning_sheetsub[(formsub.id, sid)] row.append(ss.filler.name()) row.append(ss.filler.email()) if not last_completed or ss.completed_at > last_completed: last_completed = ss.completed_at else: ss = None row.append(None) row.append(None) if info['is_initial']: if ss: row.append(ss.given_at.strftime(DATETIME_FMT)) else: row.append(None) for fid, finfo in info['fields'].iteritems(): if ss and (ss.id, fid) in fieldsub_lookup: fs = fieldsub_lookup[(ss.id, fid)] handler = FIELD_TYPE_MODELS[fs.field.fieldtype]( fs.field.config) row.append(handler.to_text(fs)) found_anything = True else: row.append(None) if last_completed: row.append(last_completed.strftime(DATETIME_FMT)) else: row.append(None) row.append(settings.BASE_ABS_URL + formsub.get_absolute_url()) if found_anything: data.append(row) return headers, data
class Page(models.Model): """ A page in this courses "web site". Actual data is versioned in PageVersion objects. """ offering = models.ForeignKey(CourseOffering) label = models.CharField( max_length=30, db_index=True, help_text="The “filename” for this page") can_read = models.CharField( max_length=4, choices=READ_ACL_CHOICES, default="ALL", help_text="Who should be able to view this page?") can_write = models.CharField( max_length=4, choices=WRITE_ACL_CHOICES, default="STAF", verbose_name="Can change", help_text="Who should be able to edit this page?") config = JSONField(null=False, blank=False, default={}) # addition configuration stuff: # p.config['releasedate']: date after which is page is visible # p.config['editdate']: date after which is page is editable defaults = {'releasedate': None, 'editdate': None} releasedate_txt, set_releasedate_txt = getter_setter('releasedate') editdate_txt, set_editdate_txt = getter_setter('editdate') class Meta: ordering = ['label'] unique_together = (('offering', 'label'), ) def save(self, *args, **kwargs): assert self.label_okay(self.label) is None super(Page, self).save(*args, **kwargs) def releasedate(self): d = self.releasedate_txt() if d is None: return None else: return datetime.datetime.strptime(d, "%Y-%m-%d").date() def editdate(self): d = self.editdate_txt() if d is None: return None else: return datetime.datetime.strptime(d, "%Y-%m-%d").date() def set_releasedate(self, val): if isinstance(val, datetime.date): val = val.strftime("%Y-%m-%d") self.set_releasedate_txt(val) def set_editdate(self, val): if isinstance(val, datetime.date): val = val.strftime("%Y-%m-%d") self.set_editdate_txt(val) def get_absolute_url(self): if self.label == 'Index': return reverse('pages.views.index_page', kwargs={'course_slug': self.offering.slug}) else: return reverse('pages.views.view_page', kwargs={ 'course_slug': self.offering.slug, 'page_label': self.label }) def version_cache_key(self): return "page-curver-" + str(self.id) def label_okay(self, label): """ Check to make sure this label is acceptable (okay characters) Used by both self.save() and model validator. """ m = label_re.match(label) if not m: return "Labels can contain only letters, numbers, underscores, dashes, and periods." def __unicode__(self): return self.offering.name() + '/' + self.label def current_version(self): """ The most recent PageVersion object for this Page Cached to save the frequent lookup. """ key = self.version_cache_key() v = cache.get(key) if v: return v else: v = PageVersion.objects.filter(page=self).select_related( 'editor__person').latest('created_at') cache.set(key, v, 3600) # expired when a PageVersion is saved return v @classmethod def adjust_acl_release(cls, acl_value, date): """ Adjust the access control value appropriately, taking the release date into account. """ if not date: # no release date, so nothing to do. return acl_value elif date and datetime.date.today() >= date: # release date passed: nothing to do. return acl_value else: # release date hasn't passed: upgrade the security level accordingly. if acl_value == 'NONE': return 'NONE' elif acl_value == 'STAF': return 'INST' else: return 'STAF' def release_message(self): return self._release_message(self.releasedate(), self.can_read, "viewable") def _release_message(self, date, acl_value, attrib): today = datetime.date.today() if not date: return None elif date > today: return "This page has not yet been released. It will be %s by %s as of %s." % ( attrib, ACL_DESC[acl_value], date) else: #return "This page was made %s automatically on %s." % (attrib, date) return None
class DiscussionMessage(models.Model): """ A message (post) associated with a Discussion Topic """ topic = models.ForeignKey(DiscussionTopic, on_delete=models.CASCADE) content = models.TextField(blank=False) created_at = models.DateTimeField(auto_now_add=True) modified_at = models.DateTimeField(auto_now=True) status = models.CharField(max_length=3, choices=MESSAGE_STATUSES, default='VIS') author = models.ForeignKey(Member, on_delete=models.PROTECT) def autoslug(self): return make_slug(self.author.person.userid) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique_with=['topic']) config = JSONField(null=False, blank=False, default=dict) # p.config['markup']: markup language used: see courselib/markup.py # p.config['math']: content uses MathJax? (boolean) # p.config['brushes']: used SyntaxHighlighter brushes (list of strings) -- no longer used with highlight.js defaults = {'math': False, 'markup': 'creole'} math, set_math = getter_setter('math') markup, set_markup = getter_setter('markup') #brushes, set_brushes = getter_setter('brushes') def save(self, *args, **kwargs): if self.status not in [status[0] for status in MESSAGE_STATUSES]: raise ValueError('Invalid topic status') if not self.pk: self.topic.new_message_update() self.content = ensure_sanitary_markup(self.content, self.markup(), restricted=True) new_message = self.id is None super(DiscussionMessage, self).save(*args, **kwargs) # handle subscriptions if new_message: subs = DiscussionSubscription.objects.filter( member__offering=self.topic.offering).select_related( 'member__person') for s in subs: s.notify_message(self) def html_content(self): "Convert self.content to HTML" return markup_to_html(self.content, self.markup(), offering=self.topic.offering, html_already_safe=True, restricted=True) def get_absolute_url(self): return self.topic.get_absolute_url() + '#reply-' + str(self.id) def create_at_delta(self): return _time_delta_to_string(self.created_at) def still_editable(self): td = datetime.datetime.now() - self.created_at seconds = td.days * 86400 + td.seconds return seconds <= 120 def editable_time_left(self): td = datetime.datetime.now() - self.created_at seconds = td.days * 86400 + td.seconds if seconds > 120: return 'none' minutes, seconds = divmod(120 - seconds, 60) return "%dm:%ds" % (minutes, seconds) def was_edited(self): td = self.modified_at - self.created_at return self.modified_at > self.created_at and td > datetime.timedelta( seconds=3) and self.status != 'HID' def exportable(self): """ Create JSON-serializable representation of message, for export. """ data = { 'body': self.content, 'created_at': self.created_at.isoformat(), 'author': self.author.person.userid, 'status': self.status } return data def to_dict(self): return { "author": self.author.person.userid, "topic": self.topic.title, "content": self.content, "visibility": self.get_status_display(), "created": str(self.created_at), "modified": str(self.modified_at) }
class TUG(models.Model): """ Time use guideline filled out by instructors Based on form in Appendix C (p. 73) of the collective agreement: http://www.tssu.ca/wp-content/uploads/2010/01/CA-2004-2010.pdf """ member = models.OneToOneField(Member, null=False, on_delete=models.PROTECT) base_units = models.DecimalField(max_digits=4, decimal_places=2, blank=False, null=False) last_update = models.DateField(auto_now=True) config = JSONField(null=False, blank=False, default=dict) # addition configuration stuff: # t.config['prep']: Preparation for labs/tutorials # t.config['meetings']: Attendance at planning meetings with instructor # t.config['lectures']: Attendance at lectures # t.config['tutorials']: Attendance at labs/tutorials # t.config['office_hours']: Office hours/student consultation # t.config['grading'] # t.config['test_prep']: Quiz/exam preparation and invigilation # t.config['holiday']: Holiday compensation # Each of the above is a dictionary like: # { # 'weekly': 2.0, # 'total': 26.0, # 'note': 'if more is required, we can revisit', # } # t.config['other1'] # t.config['other2'] # As the other fields, but adding 'label'. prep = property(*getter_setter('prep')) meetings = property(*getter_setter('meetings')) lectures = property(*getter_setter('lectures')) tutorials = property(*getter_setter('tutorials')) office_hours = property(*getter_setter('office_hours')) grading = property(*getter_setter('grading')) test_prep = property(*getter_setter('test_prep')) holiday = property(*getter_setter('holiday')) other1 = property(*getter_setter('other1')) other2 = property(*getter_setter('other2')) def iterothers(self): return (other for key, other in self.config.items() if key.startswith('other') and isinstance( other.get('total'), float) and other.get('total', 0) > 0) others = lambda self: list(self.iterothers()) def iterfielditems(self): return ((field, self.config[field]) for field in self.all_fields if field in self.config) regular_fields = [ 'prep', 'meetings', 'lectures', 'tutorials', 'office_hours', 'grading', 'test_prep', 'holiday' ] other_fields = ['other1', 'other2'] all_fields = regular_fields + other_fields defaults = dict([(field, { 'weekly': 0, 'total': 0, 'comment': '' }) for field in regular_fields] + [(field, { 'label': '', 'weekly': 0, 'total': 0, 'comment': '' }) for field in other_fields]) # depicts the above comment in code config_meta = { 'prep': { 'label': 'Preparation', 'help': '1. Preparation for labs/tutorials' }, 'meetings': { 'label': 'Attendance at planning meetings', 'help': '2. Attendance at planning/coordinating meetings with instructor' }, 'lectures': { 'label': 'Attendance at lectures', 'help': '3. Attendance at lectures' }, 'tutorials': { 'label': 'Attendance at labs/tutorials', 'help': '4. Attendance at labs/tutorials' }, 'office_hours': { 'label': 'Office hours', 'help': '5. Office hours/student consultation/electronic communication' }, 'grading': { 'label': 'Grading', 'help': '6. Grading\u2020', 'extra': '\u2020Includes grading of all assignments, reports and examinations.' }, 'test_prep': { 'label': 'Quiz/exam preparation and invigilation', 'help': '7. Quiz preparation/assist in exam preparation/Invigilation of exams' }, 'holiday': { 'label': 'Holiday compensation', 'help': '8. Statutory Holiday Compensation\u2021', 'extra': '''\u2021To compensate for all statutory holidays which may occur in a semester, the total workload required will be reduced by %s hour(s) for each base unit assigned excluding the additional %s B.U. for preparation, e.g. %s hours reduction for %s B.U. appointment.''' % (HOLIDAY_HOURS_PER_BU, LAB_BONUS, 4.4, 4 + LAB_BONUS) } } def __str__(self): return "TA: %s Base Units: %s" % (self.member.person.userid, self.base_units) def save(self, newsitem=True, newsitem_author=None, *args, **kwargs): for f in self.config: # if 'weekly' in False is invalid, so we have to check if self.config[f] is iterable # before we check for 'weekly' or 'total' if hasattr(self.config[f], '__iter__'): if 'weekly' in self.config[f]: self.config[f]['weekly'] = _round_hours( self.config[f]['weekly']) if 'total' in self.config[f]: self.config[f]['total'] = _round_hours( self.config[f]['total']) super(TUG, self).save(*args, **kwargs) if newsitem: n = NewsItem( user=self.member.person, author=newsitem_author, course=self.member.offering, source_app='ta', title='%s Time Use Guideline Changed' % (self.member.offering.name()), content= 'Your Time Use Guideline for %s has been changed. If you have not already, please review it with the instructor.' % (self.member.offering.name()), url=self.get_absolute_url()) n.save() def get_absolute_url(self): return reverse('offering:view_tug', kwargs={ 'course_slug': self.member.offering.slug, 'userid': self.member.person.userid }) def max_hours(self): return self.base_units * HOURS_PER_BU def total_hours(self): """ Total number of hours assigned """ return round( sum((decimal.Decimal(data['total']) for _, data in self.iterfielditems() if data['total'])), 2)
class TAPosting(models.Model): """ Posting for one unit in one semester """ semester = models.ForeignKey(Semester, on_delete=models.PROTECT) unit = models.ForeignKey(Unit, on_delete=models.PROTECT) opens = models.DateField(help_text='Opening date for the posting') closes = models.DateField(help_text='Closing date for the posting') def autoslug(self): return make_slug(self.semester.slugform() + "-" + self.unit.label) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique=True) config = JSONField(null=False, blank=False, default=dict) # addition configuration stuff: # 'salary': default pay rates per BU for each GTA1, GTA2, UTA, EXT: ['1.00', '2.00', '3.00', '4.00'] # 'scholarship': default scholarship rates per BU for each GTA1, GTA2, UTA, EXT # 'accounts': default accounts for GTA1, GTA2, UTA, EXT (ra.models.Account.id values) # 'start': default start date for contracts ('YYYY-MM-DD') # 'end': default end date for contracts ('YYYY-MM-DD') # 'payroll_start': default payroll start date for contracts ('YYYY-MM-DD') # 'payroll_end': default payroll start date for contracts ('YYYY-MM-DD') # 'deadline': default deadline to accept contracts ('YYYY-MM-DD') # 'excluded': courses to exclude from posting (list of Course.id values) # 'payperiods': number of pay periods in the semeseter # 'contact': contact person for offer questions (Person.id value) # 'max_courses': Maximum number of courses an applicant can select # 'min_courses': Minimum number of courses an applicant can select # 'offer_text': Text to be displayed when students accept/reject the offer (creole markup) # 'export_seq': sequence ID for payroll export (so we can create a unique Batch ID) # 'extra_questions': additional questions to ask applicants # 'instructions': instructions for completing the TA Application # 'hide_campuses': whether or not to prompt for Campus defaults = { 'salary': ['0.00'] * len(CATEGORY_CHOICES), 'scholarship': ['0.00'] * len(CATEGORY_CHOICES), 'accounts': [None] * len(CATEGORY_CHOICES), 'start': '', 'end': '', 'payroll_start': '', 'payroll_end': '', 'deadline': '', 'excluded': [], 'bu_defaults': {}, 'payperiods': 8, 'max_courses': 10, 'min_courses': 0, 'contact': None, 'offer_text': '', 'export_seq': 0, 'extra_questions': [], 'instructions': '', 'hide_campuses': False } salary, set_salary = getter_setter('salary') scholarship, set_scholarship = getter_setter('scholarship') accounts, set_accounts = getter_setter('accounts') start, set_start = getter_setter('start') end, set_end = getter_setter('end') payroll_start, set_payroll_start = getter_setter('payroll_start') payroll_end, set_payroll_end = getter_setter('payroll_end') deadline, set_deadline = getter_setter('deadline') excluded, set_excluded = getter_setter('excluded') bu_defaults, set_bu_defaults = getter_setter('bu_defaults') payperiods_str, set_payperiods = getter_setter('payperiods') max_courses, set_max_courses = getter_setter('max_courses') min_courses, set_min_courses = getter_setter('min_courses') offer_text, set_offer_text = getter_setter('offer_text') extra_questions, set_extra_questions = getter_setter('extra_questions') instructions, set_instructions = getter_setter('instructions') hide_campuses, set_hide_campuses = getter_setter('hide_campuses') _, set_contact = getter_setter('contact') class Meta: unique_together = (('unit', 'semester'), ) def __str__(self): return "%s, %s" % (self.unit.name, self.semester) def save(self, *args, **kwargs): super(TAPosting, self).save(*args, **kwargs) key = self.html_cache_key() cache.delete(key) def short_str(self): return "%s %s" % (self.unit.label, self.semester) def delete(self, *args, **kwargs): raise NotImplementedError( "This object cannot be deleted because it is used as a foreign key." ) def contact(self): if 'contact' in self.config: return Person.objects.get(id=self.config['contact']) else: return None def payperiods(self): return decimal.Decimal(self.payperiods_str()) def selectable_courses(self): """ Course objects that can be selected as possible choices """ excl = set(self.excluded()) offerings = CourseOffering.objects.filter( semester=self.semester, owner=self.unit).select_related('course') # remove duplicates and sort nicely courses = list( set((o.course for o in offerings if o.course_id not in excl))) courses.sort() return courses def selectable_offerings(self): """ CourseOffering objects that can be selected as possible choices """ excl = set(self.excluded()) offerings = CourseOffering.objects.filter( semester=self.semester, owner=self.unit).exclude(course__id__in=excl) return offerings def is_open(self): today = datetime.date.today() return self.opens <= today <= self.closes def next_export_seq(self): if 'export_seq' in self.config: current = self.config['export_seq'] else: current = 0 self.config['export_seq'] = current + 1 self.save() return self.config['export_seq'] def cat_index(self, val): indexer = dict((v[0], k) for k, v in enumerate(CATEGORY_CHOICES)) return indexer.get(val) def default_bu(self, offering, count=None): """ Default BUs to assign for this course offering """ strategy = bu_rules.get_bu_strategy(self.semester, self.unit) return strategy(self, offering, count) def required_bu(self, offering, count=None): """ Actual BUs to assign to this course: default + extra + 0.17*number of TA's """ default = self.default_bu(offering, count=count) extra = offering.extra_bu() if offering.labtas(): tacourses = TACourse.objects.filter( contract__posting=self, course=offering).exclude(contract__status__in=['REJ', 'CAN']) return default + extra + decimal.Decimal( LAB_BONUS_DECIMAL * len(tacourses)) else: return default + extra def required_bu_cap(self, offering): """ Actual BUs to assign to this course at its enrolment cap """ default = self.default_bu(offering, offering.enrl_cap) extra = offering.extra_bu() return default + extra def assigned_bu(self, offering): """ BUs already assigned to this course """ total = decimal.Decimal(0) tacourses = TACourse.objects.filter( contract__posting=self, course=offering).exclude(contract__status__in=['REJ', 'CAN']) if (tacourses.count() > 0): total = sum([t.total_bu for t in tacourses]) return decimal.Decimal(total) def applicant_count(self, offering): """ Number of people who have applied to TA this offering """ prefs = CoursePreference.objects.filter( app__posting=self, app__late=False, course=offering.course).exclude(rank=0) return prefs.count() def ta_count(self, offering): """ Number of people who have assigned to be TA for this offering """ tacourses = TACourse.objects.filter( contract__posting=self, course=offering).exclude(contract__status__in=['REJ', 'CAN']) return tacourses.count() def total_pay(self, offering): """ Payments for all tacourses associated with this offering """ total = 0 tacourses = TACourse.objects.filter( course=offering, contract__posting=self).exclude( contract__status__in=['REJ', 'CAN']) for course in tacourses: total += course.pay() return total def all_total(self): """ BU's and Payments for all tacourses associated with all offerings """ pay = 0 bus = 0 tac = TAContract.objects.filter(posting=self).exclude( status__in=['REJ', 'CAN']).count() tacourses = TACourse.objects.filter(contract__posting=self).exclude( contract__status__in=['REJ', 'CAN']) for course in tacourses: pay += course.pay() bus += course.total_bu return (bus, pay, tac) def html_cache_key(self): return "taposting-offertext-html-" + str(self.id) def html_offer_text(self): """ Return the HTML version of this offer's offer_text Cached to save frequent conversion. """ key = self.html_cache_key() html = cache.get(key) if html: return mark_safe(html) else: html = markup_to_html(self.offer_text(), 'creole') cache.set(key, html, 24 * 3600) # expires on self.save() above return html
class DiscussionTopic(models.Model): """ A topic (thread) associated with a CourseOffering """ offering = models.ForeignKey(CourseOffering, null=False) title = models.CharField(max_length=140, help_text="A brief description of the topic") content = models.TextField( help_text= 'The inital message for the topic, <a href="http://www.wikicreole.org/wiki/Creole1.0">WikiCreole-formatted</a>' ) created_at = models.DateTimeField(auto_now_add=True) last_activity_at = models.DateTimeField(auto_now_add=True) message_count = models.IntegerField(default=0) status = models.CharField( max_length=3, choices=TOPIC_STATUSES, default='OPN', help_text= "The topic status: Closed: no replies allowed, Hidden: cannot be seen") pinned = models.BooleanField( default=False, help_text="Should this topic be pinned to bring attention?") author = models.ForeignKey(Member) def autoslug(self): return make_slug(self.title) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique_with=['offering']) config = JSONField(null=False, blank=False, default={}) # p.config['math']: content uses MathJax? (boolean) # p.config['brushes']: used SyntaxHighlighter brushes (list of strings) defaults = {'math': False, 'brushes': []} math, set_math = getter_setter('math') brushes, set_brushes = getter_setter('brushes') def save(self, *args, **kwargs): if self.status not in [status[0] for status in TOPIC_STATUSES]: raise ValueError('Invalid topic status') # update the metainfo about creole display self.get_creole() brushes = brushes_used(self.Creole.parser.parse(self.content)) self.set_brushes(list(brushes)) # TODO: nobody sets config.math to True, but it is honoured if it is set magically. UI for that? new_topic = self.id is None super(DiscussionTopic, self).save(*args, **kwargs) # handle subscriptions if new_topic: subs = DiscussionSubscription.objects.filter( member__offering=self.offering).select_related( 'member__person') for s in subs: s.notify(self) def get_absolute_url(self): return reverse('offering:discussion:view_topic', kwargs={ 'course_slug': self.offering.slug, 'topic_slug': self.slug }) def new_message_update(self): self.last_activity_at = datetime.datetime.now() self.message_count = self.message_count + 1 self.save() def last_activity_at_delta(self): return _time_delta_to_string(self.last_activity_at) def created_at_delta(self): return _time_delta_to_string(self.created_at) def __unicode___(self): return self.title def __init__(self, *args, **kwargs): super(DiscussionTopic, self).__init__(*args, **kwargs) self.Creole = None def get_creole(self): "Only build the creole parser on-demand." if not self.Creole: self.Creole = ParserFor(self.offering) return self.Creole def html_content(self): "Convert self.content to HTML" creole = self.get_creole() html = creole.text2html(self.content) return mark_safe(html) def still_editable(self): td = datetime.datetime.now() - self.created_at seconds = td.days * 86400 + td.seconds return seconds <= 120 def editable_time_left(self): td = datetime.datetime.now() - self.created_at seconds = td.days * 86400 + td.seconds if seconds > 120: return 'none' minutes, seconds = divmod(120 - seconds, 60) return "%dm:%ds" % (minutes, seconds) def exportable(self): """ Create JSON-serializable representation of topic, for export. """ data = { 'title': self.title, 'body': self.content, 'created_at': self.created_at.isoformat(), 'author': self.author.person.userid, 'status': self.status, 'pinned': self.pinned } messages = DiscussionMessage.objects.filter( topic=self).select_related('author__person') data['replies'] = [m.exportable() for m in messages] return data
class DiscussionMessage(models.Model): """ A message (post) associated with a Discussion Topic """ topic = models.ForeignKey(DiscussionTopic) content = models.TextField( blank=False, help_text=mark_safe( 'Reply to topic, <a href="http://www.wikicreole.org/wiki/Creole1.0">WikiCreole-formatted</a>' )) created_at = models.DateTimeField(auto_now_add=True) modified_at = models.DateTimeField(auto_now=True) status = models.CharField(max_length=3, choices=MESSAGE_STATUSES, default='VIS') author = models.ForeignKey(Member) def autoslug(self): return make_slug(self.author.person.userid) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique_with=['topic']) config = JSONField(null=False, blank=False, default={}) # p.config['math']: content uses MathJax? (boolean) # p.config['brushes']: used SyntaxHighlighter brushes (list of strings) defaults = {'math': False, 'brushes': []} math, set_math = getter_setter('math') brushes, set_brushes = getter_setter('brushes') def save(self, *args, **kwargs): if self.status not in [status[0] for status in MESSAGE_STATUSES]: raise ValueError('Invalid topic status') if not self.pk: self.topic.new_message_update() # update the metainfo about creole display self.topic.get_creole() brushes = brushes_used(self.topic.Creole.parser.parse(self.content)) self.set_brushes(list(brushes)) new_message = self.id is None super(DiscussionMessage, self).save(*args, **kwargs) # handle subscriptions if new_message: subs = DiscussionSubscription.objects.filter( member__offering=self.topic.offering).select_related( 'member__person') for s in subs: s.notify_message(self) subs = TopicSubscription.objects.filter( member__offering=self.topic.offering, topic=self.topic).select_related('member__person') for s in subs: s.notify(self) def html_content(self): "Convert self.content to HTML" creole = self.topic.get_creole() html = creole.text2html(self.content) return mark_safe(html) def get_absolute_url(self): return self.topic.get_absolute_url() + '#reply-' + str(self.id) def create_at_delta(self): return _time_delta_to_string(self.created_at) def still_editable(self): td = datetime.datetime.now() - self.created_at seconds = td.days * 86400 + td.seconds return seconds <= 120 def editable_time_left(self): td = datetime.datetime.now() - self.created_at seconds = td.days * 86400 + td.seconds if seconds > 120: return 'none' minutes, seconds = divmod(120 - seconds, 60) return "%dm:%ds" % (minutes, seconds) def was_edited(self): td = self.modified_at - self.created_at return self.modified_at > self.created_at and td > datetime.timedelta( seconds=3) and self.status != 'HID' def exportable(self): """ Create JSON-serializable representation of message, for export. """ data = { 'body': self.content, 'created_at': self.created_at.isoformat(), 'author': self.author.person.userid, 'status': self.status } return data
class RAAppointment(models.Model): """ This stores information about a (Research Assistant)s application and pay. """ person = models.ForeignKey(Person, help_text='The RA who is being appointed.', null=False, blank=False, related_name='ra_person', on_delete=models.PROTECT) sin = models.PositiveIntegerField(null=True, blank=True) # We want do add some sort of accountability for checking visas. visa_verified = models.BooleanField( default=False, help_text='I have verified this RA\'s visa information') hiring_faculty = models.ForeignKey( Person, help_text='The manager who is hiring the RA.', related_name='ra_hiring_faculty', on_delete=models.PROTECT) unit = models.ForeignKey(Unit, help_text='The unit that owns the appointment', null=False, blank=False, on_delete=models.PROTECT) hiring_category = models.CharField(max_length=4, choices=HIRING_CATEGORY_CHOICES, default='GRA') scholarship = models.ForeignKey( Scholarship, null=True, blank=True, help_text='Scholarship associated with this appointment. Optional.', on_delete=models.PROTECT) project = models.ForeignKey(Project, null=False, blank=False, on_delete=models.PROTECT) account = models.ForeignKey( Account, null=False, blank=False, help_text='This is now called "Object" in the new PAF', on_delete=models.PROTECT) program = models.ForeignKey( Program, null=True, blank=True, help_text='If none is provided, "00000" will be added in the PAF', on_delete=models.PROTECT) start_date = models.DateField(auto_now=False, auto_now_add=False) end_date = models.DateField(auto_now=False, auto_now_add=False) pay_frequency = models.CharField(max_length=60, choices=PAY_FREQUENCY_CHOICES, default='B') lump_sum_pay = models.DecimalField(max_digits=8, decimal_places=2, verbose_name="Total Pay") lump_sum_hours = models.DecimalField(max_digits=8, decimal_places=2, verbose_name="Total Hours", blank=True, null=True) biweekly_pay = models.DecimalField(max_digits=8, decimal_places=2) pay_periods = models.DecimalField(max_digits=6, decimal_places=1) hourly_pay = models.DecimalField(max_digits=8, decimal_places=2) hours = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="Biweekly Hours") reappointment = models.BooleanField( default=False, help_text="Are we re-appointing to the same position?") medical_benefits = models.BooleanField( default=False, help_text="50% of Medical Service Plan") dental_benefits = models.BooleanField(default=False, help_text="50% of Dental Plan") # The two following fields verbose names are reversed for a reason. They were named incorrectly with regards to # the PAF we generate, so the verbose names are correct. notes = models.TextField( "Comments", blank=True, help_text= "Biweekly employment earnings rates must include vacation pay, hourly rates will automatically have vacation pay added. The employer cost of statutory benefits will be charged to the amount to the earnings rate." ) comments = models.TextField("Notes", blank=True, help_text="For internal use") offer_letter_text = models.TextField( null=True, help_text= "Text of the offer letter to be signed by the RA and supervisor.") def autoslug(self): if self.person.userid: ident = self.person.userid else: ident = str(self.person.emplid) return make_slug(self.unit.label + '-' + str(self.start_date.year) + '-' + ident) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique=True) created_at = models.DateTimeField(auto_now_add=True) deleted = models.BooleanField(null=False, default=False) config = JSONField(null=False, blank=False, default=dict) # addition configuration stuff defaults = {'use_hourly': False} use_hourly, set_use_hourly = getter_setter('use_hourly') def __str__(self): return str(self.person) + "@" + str(self.created_at) class Meta: ordering = ['person', 'created_at'] def save(self, *args, **kwargs): # set SIN field on the Person object if self.sin and 'sin' not in self.person.config: self.person.set_sin(self.sin) self.person.save() super(RAAppointment, self).save(*args, **kwargs) def get_absolute_url(self): return reverse('ra:view', kwargs={'ra_slug': self.slug}) def mark_reminded(self): self.config['reminded'] = True self.save() @staticmethod def letter_choices(units): """ Return a form choices list for RA letter templates in these units. Ignores the units for now: we want to allow configurability later. """ return [(key, label) for (key, (label, _, _)) in list(DEFAULT_LETTERS.items())] def build_letter_text(self, selection): """ This takes the value passed from the letter selector menu and builds the appropriate default letter based on that. """ substitutions = { 'start_date': self.start_date.strftime("%B %d, %Y"), 'end_date': self.end_date.strftime("%B %d, %Y"), 'lump_sum_pay': self.lump_sum_pay, 'biweekly_pay': self.biweekly_pay, } _, lumpsum_text, biweekly_text = DEFAULT_LETTERS[selection] if self.pay_frequency == 'B': text = biweekly_text else: text = lumpsum_text letter_text = text % substitutions self.offer_letter_text = letter_text self.save() def letter_paragraphs(self): """ Return list of paragraphs in the letter (for PDF creation) """ text = self.offer_letter_text text = normalize_newlines(text) return text.split("\n\n") @classmethod def semester_guess(cls, date): """ Guess the semester for a date, in the way that financial people do (without regard to class start/end dates) """ mo = date.month if mo <= 4: se = 1 elif mo <= 8: se = 4 else: se = 7 semname = str((date.year - 1900) * 10 + se) return Semester.objects.get(name=semname) @classmethod def start_end_dates(cls, semester): """ First and last days of the semester, in the way that financial people do (without regard to class start/end dates) """ return Semester.start_end_dates(semester) #yr = int(semester.name[0:3]) + 1900 #sm = int(semester.name[3]) #if sm == 1: # start = datetime.date(yr, 1, 1) # end = datetime.date(yr, 4, 30) #elif sm == 4: # start = datetime.date(yr, 5, 1) # end = datetime.date(yr, 8, 31) #elif sm == 7: # start = datetime.date(yr, 9, 1) # end = datetime.date(yr, 12, 31) #return start, end def start_semester(self): "Guess the starting semester of this appointment" start_semester = RAAppointment.semester_guess(self.start_date) # We do this to eliminate hang - if you're starting N days before # semester 1134, you aren't splitting that payment across 2 semesters. start, end = RAAppointment.start_end_dates(start_semester) if end - self.start_date < datetime.timedelta(SEMESTER_SLIDE): return start_semester.next_semester() return start_semester def end_semester(self): "Guess the ending semester of this appointment" end_semester = RAAppointment.semester_guess(self.end_date) # We do this to eliminate hang - if you're starting N days after # semester 1134, you aren't splitting that payment across 2 semesters. start, end = RAAppointment.start_end_dates(end_semester) if self.end_date - start < datetime.timedelta(SEMESTER_SLIDE): return end_semester.previous_semester() return end_semester def semester_length(self): "The number of semesters this contracts lasts for" return self.end_semester() - self.start_semester() + 1 @classmethod def expiring_appointments(cls): """ Get the list of RA Appointments that will expire in the next few weeks so we can send a reminder email """ today = datetime.datetime.now() min_age = datetime.datetime.now() + datetime.timedelta(days=14) expiring_ras = RAAppointment.objects.filter(end_date__gt=today, end_date__lte=min_age, deleted=False) ras = [ ra for ra in expiring_ras if 'reminded' not in ra.config or not ra.config['reminded'] ] return ras @classmethod def email_expiring_ras(cls): """ Emails the supervisors of the RAs who have appointments that are about to expire. """ subject = 'RA appointment expiry reminder' from_email = settings.DEFAULT_FROM_EMAIL expiring_ras = cls.expiring_appointments() template = get_template('ra/emails/reminder.txt') for raappt in expiring_ras: supervisor = raappt.hiring_faculty context = {'supervisor': supervisor, 'raappt': raappt} # Let's see if we have any Funding CC supervisors that should also get the reminder. cc = None fund_cc_roles = Role.objects_fresh.filter(unit=raappt.unit, role='FDCC') # If we do, let's add them to the CC list, but let's also make sure to use their role account email for # the given role type if it exists. if fund_cc_roles: people = [] for role in fund_cc_roles: people.append(role.person) people = list(set(people)) cc = [] for person in people: cc.append(person.role_account_email('FDCC')) msg = EmailMultiAlternatives(subject, template.render(context), from_email, [supervisor.email()], headers={'X-coursys-topic': 'ra'}, cc=cc) msg.send() raappt.mark_reminded() def get_program_display(self): if self.program: return self.program.get_program_number_display() else: return '00000' def has_attachments(self): return self.attachments.visible().count() > 0
class DiscussionTopic(models.Model): """ A topic (thread) associated with a CourseOffering """ offering = models.ForeignKey(CourseOffering, null=False, on_delete=models.PROTECT) title = models.CharField(max_length=140, help_text="A brief description of the topic") content = models.TextField(help_text='The inital message for the topic.') created_at = models.DateTimeField(auto_now_add=True) last_activity_at = models.DateTimeField(auto_now_add=True) message_count = models.IntegerField(default=0) status = models.CharField( max_length=3, choices=TOPIC_STATUSES, default='OPN', help_text= "The topic status: Closed: no replies allowed, Hidden: cannot be seen") pinned = models.BooleanField( default=False, help_text="Should this topic be pinned to bring attention?") author = models.ForeignKey(Member, on_delete=models.PROTECT) def autoslug(self): return make_slug(self.title) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique_with=['offering']) config = JSONField(null=False, blank=False, default=dict) # p.config['markup']: markup language used: see courselib/markup.py # p.config['math']: content uses MathJax? (boolean) # p.config['brushes']: used SyntaxHighlighter brushes (list of strings) -- no longer used with highlight.js defaults = { 'markup': 'creole', 'math': False, } markup, set_markup = getter_setter('markup') math, set_math = getter_setter('math') def save(self, *args, **kwargs): if self.status not in [status[0] for status in TOPIC_STATUSES]: raise ValueError('Invalid topic status') self.content = ensure_sanitary_markup(self.content, self.markup(), restricted=True) new_topic = self.id is None super(DiscussionTopic, self).save(*args, **kwargs) # handle subscriptions if new_topic: subs = DiscussionSubscription.objects.filter( member__offering=self.offering).select_related( 'member__person') for s in subs: s.notify(self) def get_absolute_url(self): return reverse('offering:discussion:view_topic', kwargs={ 'course_slug': self.offering.slug, 'topic_slug': self.slug }) def new_message_update(self): self.last_activity_at = datetime.datetime.now() self.message_count = self.message_count + 1 self.save() def last_activity_at_delta(self): return _time_delta_to_string(self.last_activity_at) def created_at_delta(self): return _time_delta_to_string(self.created_at) def __str___(self): return self.title def html_content(self): "Convert self.content to HTML" return markup_to_html(self.content, self.markup(), offering=self.offering, html_already_safe=True, restricted=True) def still_editable(self): td = datetime.datetime.now() - self.created_at seconds = td.days * 86400 + td.seconds return seconds <= 120 def editable_time_left(self): td = datetime.datetime.now() - self.created_at seconds = td.days * 86400 + td.seconds if seconds > 120: return 'none' minutes, seconds = divmod(120 - seconds, 60) return "%dm:%ds" % (minutes, seconds) def exportable(self): """ Create JSON-serializable representation of topic, for export. """ data = { 'title': self.title, 'body': self.content, 'created_at': self.created_at.isoformat(), 'author': self.author.person.userid, 'status': self.status, 'pinned': self.pinned } messages = DiscussionMessage.objects.filter( topic=self).select_related('author__person') data['replies'] = [m.exportable() for m in messages] return data
class Activity(models.Model): """ Generic activity (i.e. column in the gradebook that can have a value assigned for each student). This should never be instantiated directly: only its sublcasses should. """ objects = models.Manager() name = models.CharField(max_length=30, db_index=True, help_text='Name of the activity.') short_name = models.CharField(max_length=15, db_index=True, help_text='Short-form name of the activity.') def autoslug(self): return make_slug(self.short_name) slug = AutoSlugField( populate_from='autoslug', null=False, editable=False, unique_with='offering', manager=objects, help_text= 'String that identifies this activity within the course offering') status = models.CharField(max_length=4, null=False, choices=ACTIVITY_STATUS_CHOICES, help_text='Activity status.') due_date = models.DateTimeField(null=True, help_text='Activity due date') percent = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True) position = models.PositiveSmallIntegerField( help_text="The order of displaying course activities.") group = models.BooleanField(null=False, default=False) deleted = models.BooleanField(null=False, db_index=True, default=False) config = JSONField(null=False, blank=False, default={}) # addition configuration stuff: # a.config['url'] (string, default None): URL for more info # a.config['showstats'] (boolean, default True): show students summary stats for this activity? # a.config['showhisto'] (boolean, default True): show students histogram for this activity? # a.config['showformula'] (boolean, default False): show students formula/cutoffs for this activity? # TODO: showformula not actually implemented yet offering = models.ForeignKey(CourseOffering) defaults = { 'url': '', 'showstats': True, 'showhisto': True, 'showformula': False } url, set_url = getter_setter('url') showstats, set_showstats = getter_setter('showstats') showhisto, set_showhisto = getter_setter('showhisto') showformula, set_showformula = getter_setter('showformula') def __unicode__(self): return u"%s - %s" % (self.offering, self.name) def short_str(self): return self.name def __cmp__(self, other): return cmp(self.position, other.position) def get_absolute_url(self): return reverse('grades.views.activity_info', kwargs={ 'course_slug': self.offering.slug, 'activity_slug': self.slug }) def delete(self, *args, **kwargs): raise NotImplementedError, "This object cannot be deleted because it is used as a foreign key." def is_numeric(self): return False class Meta: verbose_name_plural = "activities" ordering = ['deleted', 'position'] unique_together = (('offering', 'slug'), ) def save(self, force_insert=False, force_update=False, newsitem=True, entered_by=None, *args, **kwargs): # get old status so we can see if it's newly-released try: old = Activity.objects.get(id=self.id) except Activity.DoesNotExist: old = None # reset slugs before semester starts if self.offering.semester.start > date.today(): self.slug = None super(Activity, self).save(*args, **kwargs) if newsitem and old and self.status == 'RLS' and old != None and old.status != 'RLS': from grades.tasks import send_grade_released_news, create_grade_released_history # newly-released grades: record that grade was released assert entered_by entered_by = get_entry_person(entered_by) create_grade_released_history(self.id, entered_by.id) # newly-released grades: create news items send_grade_released_news(self.id) if old and old.group and not self.group: # activity changed group -> individual. Clean out any group memberships from groups.models import GroupMember GroupMember.objects.filter(activity=self).delete() def safely_delete(self): """ Do the actions to safely "delete" the activity. """ with transaction.atomic(): # mangle name and short-name so instructors can delete and replace i = 1 while True: suffix = "__%04i" % (i) existing = Activity.objects.filter(offering=self.offering, name=self.name+suffix).count() \ + Activity.objects.filter(offering=self.offering, short_name=self.short_name+suffix).count() if existing == 0: break i += 1 # update the activity self.deleted = True self.name = self.name + suffix self.short_name = self.short_name + suffix self.slug = None self.save() def display_label(self): if self.percent: return "%s (%s%%)" % (self.name, self.percent) else: return "%s" % (self.name) def display_grade_student(self, student): """ String representing grade for this student """ return self.display_grade_visible(student, 'STUD') def display_grade_staff(self, student): """ String representing grade for this student """ return self.display_grade_visible(student, 'INST') def get_status_display(self): """ Override to provide better string for not-yet-due case. """ if self.status == "URLS" and self.due_date and self.due_date > datetime.now( ): return "no grades: due date not passed" return ACTIVITY_STATUS[self.status] def get_status_display_staff(self): """ Override to provide better string for not-yet-due case. """ if self.status == "URLS" and self.due_date and self.due_date > datetime.now( ): return "no grades: due date not passed" elif self.status == 'URLS': total = Member.objects.filter(offering=self.offering, role='STUD').count() if isinstance(self, NumericActivity): GradeClass = NumericGrade elif isinstance(self, LetterActivity): GradeClass = LetterGrade graded = GradeClass.objects.filter(activity=self).exclude( flag='NOGR').count() # If we've graded everything, might as well change the status back if graded == total and total > 0: return ACTIVITY_STATUS[self.status] # Otherwise, let them know the progress. return 'ready to grade (%i/%i graded)' % (graded, total) return ACTIVITY_STATUS[self.status] def markable(self): """ Returns True if this activity is "markable". i.e. has any marking components defined. """ return self.activitycomponent_set.all().count() != 0 def submitable(self): """ Returns True if this activity is "submittable". i.e. has any submission components defined and within 30 days after due date """ comp_count = self.submissioncomponent_set.filter(deleted=False).count() return comp_count != 0 and not self.too_old() def no_submit_too_old(self): """ Returns True if this activity was submittable but is now too old """ comp_count = self.submissioncomponent_set.filter(deleted=False).count() return comp_count != 0 and self.too_old() def too_old(self): """ Returns True if this activity is not submittable because it is too old """ if not self.due_date: return False now = datetime.now() return now - self.due_date >= timedelta(days=30) def SubmissionClass(self): from submission.models import StudentSubmission, GroupSubmission if self.group: return GroupSubmission else: return StudentSubmission def due_in_future(self): return self.due_date and self.due_date > datetime.now() def due_in_str(self): """ Produce pretty string for "in how long is this due" """ if not self.due_date: return u"\u2014" due_in = self.due_date - datetime.now() seconds = ( due_in.microseconds + (due_in.seconds + due_in.days * 24 * 3600.0) * 10**6) / 10**6 if due_in < timedelta(seconds=0): return u'\u2014' elif due_in > timedelta(days=2): return "%i days" % (due_in.days) elif due_in > timedelta(days=1): return "%i day %i hours" % (due_in.days, int((seconds / 3600) - (due_in.days * 24))) elif due_in > timedelta(hours=2): return "%i hours %i minutes" % (int( seconds / 3600), int(seconds % 3600 / 60)) elif due_in > timedelta(hours=1): return "%i hour %i minutes" % (int( seconds / 3600), int(seconds % 3600 / 60)) else: return "%i minutes" % (int(seconds / 60)) def due_class(self): """ Produce class name for "heat": how long until it is due? """ if not self.due_date: return "due_unknown" due_in = self.due_date - datetime.now() if due_in < timedelta(seconds=0): return "due_overdue" elif due_in < timedelta(days=2): return "due_verysoon" elif due_in < timedelta(days=7): return "due_soon" else: return "due_far"
class RAAppointment(models.Model): """ This stores information about a (Research Assistant)s application and pay. """ person = models.ForeignKey(Person, help_text='The RA who is being appointed.', null=False, blank=False, related_name='ra_person') sin = models.PositiveIntegerField(null=True, blank=True) hiring_faculty = models.ForeignKey( Person, help_text='The manager who is hiring the RA.', related_name='ra_hiring_faculty') unit = models.ForeignKey(Unit, help_text='The unit that owns the appointment', null=False, blank=False) hiring_category = models.CharField(max_length=4, choices=HIRING_CATEGORY_CHOICES, default='GRA') scholarship = models.ForeignKey( Scholarship, null=True, blank=True, help_text='Scholarship associated with this appointment. Optional.') project = models.ForeignKey(Project, null=False, blank=False) account = models.ForeignKey(Account, null=False, blank=False) start_date = models.DateField(auto_now=False, auto_now_add=False) end_date = models.DateField(auto_now=False, auto_now_add=False) pay_frequency = models.CharField(max_length=60, choices=PAY_FREQUENCY_CHOICES, default='B') lump_sum_pay = models.DecimalField(max_digits=8, decimal_places=2, verbose_name="Total Pay") lump_sum_hours = models.DecimalField(max_digits=8, decimal_places=2, verbose_name="Total Hours", blank=True, null=True) biweekly_pay = models.DecimalField(max_digits=8, decimal_places=2) pay_periods = models.DecimalField(max_digits=6, decimal_places=1) hourly_pay = models.DecimalField(max_digits=8, decimal_places=2) hours = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="Biweekly Hours") reappointment = models.BooleanField( default=False, help_text="Are we re-appointing to the same position?") medical_benefits = models.BooleanField( default=False, help_text="50% of Medical Service Plan") dental_benefits = models.BooleanField(default=False, help_text="50% of Dental Plan") notes = models.TextField( blank=True, help_text= "Biweekly emplyment earnings rates must include vacation pay, hourly rates will automatically have vacation pay added. The employer cost of statutory benefits will be charged to the amount to the earnings rate." ) comments = models.TextField(blank=True, help_text="For internal use") offer_letter_text = models.TextField( null=True, help_text= "Text of the offer letter to be signed by the RA and supervisor.") def autoslug(self): if self.person.userid: ident = self.person.userid else: ident = unicode(self.person.emplid) return make_slug(self.unit.label + '-' + unicode(self.start_date.year) + '-' + ident) slug = AutoSlugField(populate_from=autoslug, null=False, editable=False, unique=True) created_at = models.DateTimeField(auto_now_add=True) deleted = models.BooleanField(null=False, default=False) config = JSONField(null=False, blank=False, default={}) # addition configuration stuff defaults = {'use_hourly': False} use_hourly, set_use_hourly = getter_setter('use_hourly') def __unicode__(self): return unicode(self.person) + "@" + unicode(self.created_at) class Meta: ordering = ['person', 'created_at'] def save(self, *args, **kwargs): # set SIN field on the Person object if self.sin and 'sin' not in self.person.config: self.person.set_sin(self.sin) self.person.save() super(RAAppointment, self).save(*args, **kwargs) def default_letter_text(self): """ Default text for the letter (for editing, or use if not set) """ substitutions = { 'start_date': self.start_date.strftime("%B %d, %Y"), 'end_date': self.end_date.strftime("%B %d, %Y"), 'lump_sum_pay': self.lump_sum_pay, 'biweekly_pay': self.biweekly_pay, } if self.pay_frequency == 'B': text = DEFAULT_LETTER_BIWEEKLY else: text = DEFAULT_LETTER_LUMPSUM return '\n\n'.join(text) % substitutions def letter_paragraphs(self): """ Return list of paragraphs in the letter (for PDF creation) """ text = self.offer_letter_text or self.default_letter_text() text = normalize_newlines(text) return text.split("\n\n") @classmethod def semester_guess(cls, date): """ Guess the semester for a date, in the way that financial people do (without regard to class start/end dates) """ mo = date.month if mo <= 4: se = 1 elif mo <= 8: se = 4 else: se = 7 semname = str((date.year - 1900) * 10 + se) return Semester.objects.get(name=semname) @classmethod def start_end_dates(cls, semester): """ First and last days of the semester, in the way that financial people do (without regard to class start/end dates) """ return Semester.start_end_dates(semester) #yr = int(semester.name[0:3]) + 1900 #sm = int(semester.name[3]) #if sm == 1: # start = datetime.date(yr, 1, 1) # end = datetime.date(yr, 4, 30) #elif sm == 4: # start = datetime.date(yr, 5, 1) # end = datetime.date(yr, 8, 31) #elif sm == 7: # start = datetime.date(yr, 9, 1) # end = datetime.date(yr, 12, 31) #return start, end def start_semester(self): "Guess the starting semester of this appointment" start_semester = RAAppointment.semester_guess(self.start_date) # We do this to eliminate hang - if you're starting N days before # semester 1134, you aren't splitting that payment across 2 semesters. start, end = RAAppointment.start_end_dates(start_semester) if end - self.start_date < datetime.timedelta(SEMESTER_SLIDE): return start_semester.next_semester() return start_semester def end_semester(self): "Guess the ending semester of this appointment" end_semester = RAAppointment.semester_guess(self.end_date) # We do this to eliminate hang - if you're starting N days after # semester 1134, you aren't splitting that payment across 2 semesters. start, end = RAAppointment.start_end_dates(end_semester) if self.end_date - start < datetime.timedelta(SEMESTER_SLIDE): return end_semester.previous_semester() return end_semester def semester_length(self): "The number of semesters this contracts lasts for" return self.end_semester() - self.start_semester() + 1