class Announcement(models.Model): title = models.CharField(max_length=100) message = models.TextField(blank=False, null=False) created_at = models.DateTimeField(default=datetime.datetime.now) author = models.ForeignKey(Person, related_name='posted_by', on_delete=models.PROTECT, help_text='The user who created the news item', editable=False) hidden = models.BooleanField(null=False, db_index=True, default=False) unit = models.ForeignKey(Unit, help_text='Academic unit who owns the note', null=False, blank=False, on_delete=models.PROTECT) config = JSONField(null=False, blank=False, default=dict) markup = config_property('markup', 'plain') math = config_property('math', False) def __str__(self): return "%s" % (self.title) class Meta: ordering = ('-created_at',) def delete(self, *args, **kwargs): raise NotImplementedError("This object cannot be deleted, set the hidden flag instead.") def html_content(self): return markup_to_html(self.message, self.markup, restricted=False)
class AdvisorNote(models.Model): """ An academic advisor's note about a student. """ text = models.TextField(blank=False, null=False, verbose_name="Contents", help_text='Note about a student') student = models.ForeignKey(Person, related_name='student', on_delete=models.PROTECT, help_text='The student that the note is about', editable=False, null=True) nonstudent = models.ForeignKey(NonStudent, editable=False, null=True, on_delete=models.PROTECT, help_text='The non-student that the note is about') advisor = models.ForeignKey(Person, related_name='advisor', on_delete=models.PROTECT, help_text='The advisor that created the note', editable=False) created_at = models.DateTimeField(default=datetime.datetime.now) file_attachment = models.FileField(storage=UploadedFileStorage, null=True, upload_to=attachment_upload_to, blank=True, max_length=500) file_mediatype = models.CharField(null=True, blank=True, max_length=200, editable=False) unit = models.ForeignKey(Unit, help_text='The academic unit that owns this note', on_delete=models.PROTECT) # Set this flag if the note is no longer to be accessible. hidden = models.BooleanField(null=False, db_index=True, default=False) emailed = models.BooleanField(null=False, default=False) config = JSONField(null=False, blank=False, default=dict) # addition configuration stuff: # 'markup': markup language used in reminder content: see courselib/markup.py # 'math': page uses MathJax? (boolean) markup = config_property('markup', 'plain') math = config_property('math', False) def __str__(self): return str(self.student) + "@" + str(self.created_at) def delete(self, *args, **kwargs): raise NotImplementedError("This object cannot be deleted, set the hidden flag instead.") class Meta: ordering = ['student', 'created_at'] def save(self, *args, **kwargs): # make sure one of student and nonstudent is there if not self.student and not self.nonstudent: raise ValueError("AdvisorNote must have either student or non-student specified.") super(AdvisorNote, self).save(*args, **kwargs) def attachment_filename(self): """ Return the filename only (no path) for the attachment. """ _, filename = os.path.split(self.file_attachment.name) return filename def unique_tuple(self): return ( make_slug(self.text[0:100]), self.created_at.isoformat() ) def __hash__(self): return self.unique_tuple().__hash__() def html_content(self): return markup_to_html(self.text, self.markup, restricted=False)
class OutreachEvent(models.Model): """ An outreach event. These are different than our other events, as they are to be attended by non-users of the system. """ title = models.CharField(max_length=60, null=False, blank=False) start_date = models.DateTimeField('Start Date and Time', default=timezone_today, help_text='Event start date and time. Use 24h format for the time if needed.') end_date = models.DateTimeField('End Date and Time', blank=False, null=False, help_text='Event end date and time') location = models.CharField(max_length=400, blank=True, null=True) description = models.CharField(max_length=800, blank=True, null=True) score = models.DecimalField(max_digits=2, decimal_places=0, max_length=2, null=True, blank=True, help_text='The score according to the event score matrix') unit = models.ForeignKey(Unit, blank=False, null=False) resources = models.CharField(max_length=400, blank=True, null=True, help_text="Resources needed for this event.") cost = models.DecimalField(blank=True, null=True, max_digits=8, decimal_places=2, help_text="Cost of this event") hidden = models.BooleanField(default=False, null=False, blank=False, editable=False) notes = models.CharField(max_length=400, blank=True, null=True, help_text='Special notes to registrants. These ' '*will* be displayed on the registration ' 'forms.') email = models.EmailField('Contact e-mail', null=True, blank=True, help_text='Contact email. Address that will be given to registrants on the registration ' 'success page in case they have any questions/problems.') closed = models.BooleanField('Close Registration', default=False, help_text='If this box is checked, people will not be able to register for this ' 'event even if it is still current.') config = JSONField(null=False, blank=False, default=dict) # 'extra_questions': additional questions to ask registrants extra_questions = config_property('extra_questions', []) objects = EventQuerySet.as_manager() def autoslug(self): return make_slug(self.unit.slug + '-' + self.title + '-' + str(self.start_date.date())) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique=True) def __unicode__(self): return u"%s - %s - %s" % (self.title, self.unit.label, self.start_date) def delete(self): """Like most of our objects, we don't want to ever really delete it.""" self.hidden = True self.save() def current(self): """ Find out if an event is still current. Otherwise, we shouldn't be able to register for it. """ return self.start_date > timezone_today() or self.end_date >= timezone_today() # TODO add copy method to copy from one event to another def registration_count(self): return OutreachEventRegistration.objects.attended_event(self).count()
class ConsumerInfo(models.Model): """ Additional info about a Consumer, augmenting that model. """ consumer = models.ForeignKey(Consumer, on_delete=models.CASCADE) timestamp = models.IntegerField( default=time ) # ConsumerInfo may change over time: the most recent with Token.timestamp >= ConsumerInfo.timestamp is the one the user agreed to. config = JSONField(null=False, blank=False, default=dict) deactivated = models.BooleanField( default=False ) # kept to record user agreement, but this will allow effectively deactivating Consumers admin_contact = config_property( 'admin_contact', None) # who we can contact for this account permissions = config_property( 'permissions', []) # things this consumer can do: list of keys for PERMISSION_OPTIONS def __str__(self): return "info for %s at %i" % (self.consumer.key, self.timestamp) def permission_descriptions(self): return (PERMISSION_OPTIONS[p] for p in self.permissions) @classmethod def get_for_token(cls, token): """ Get the ConsumerInfo that the user agreed to with this token (since it's possible the permission list changes over time), and return the permission list they agreed to """ return ConsumerInfo.objects.filter(consumer_id=token.consumer_id, timestamp__lt=token.timestamp) \ .order_by('-timestamp').first() @classmethod def allowed_permissions(cls, token): ci = ConsumerInfo.get_for_token(token) if not ci or ci.deactivated: return [] else: return ci.permissions
class Question(models.Model, ConditionalSaveMixin): class QuestionStatusManager(models.Manager): def get_queryset(self): return super().get_queryset().select_related('quiz').prefetch_related('versions').filter(status='V') quiz = models.ForeignKey(Quiz, null=False, blank=False, on_delete=models.PROTECT) type = models.CharField(max_length=4, null=False, blank=False, choices=QUESTION_TYPE_CHOICES) status = models.CharField(max_length=1, null=False, blank=False, default='V', choices=STATUS_CHOICES) order = models.PositiveSmallIntegerField(null=False, blank=False) config = JSONField(null=False, blank=False, default=dict) # .config['points']: points the question is worth (positive integer) points = config_property('points', default=1) class Meta: ordering = ['order'] objects = QuestionStatusManager() all_objects = models.Manager() def get_absolute_url(self): return resolve_url('offering:quiz:index', course_slug=self.quiz.activity.offering.slug, activity_slug=self.quiz.activity.slug) + '#' + self.ident() def ident(self): """ Unique identifier that can be used as a input name or HTML id value. """ return 'q-%i' % (self.id,) def set_order(self): """ If the question has no .order, set the .order value to the current max + 1 """ if self.order is not None: return current_max = Question.objects.filter(quiz=self.quiz).aggregate(Max('order'))['order__max'] if not current_max: self.order = 1 else: self.order = current_max + 1 def export(self) -> Dict[str, Any]: return { 'points': self.points, 'type': self.type, 'versions': [v.export() for v in self.versions.all()] }
class OutreachEvent(models.Model): """ An outreach event. These are different than our other events, as they are to be attended by non-users of the system. """ title = models.CharField(max_length=60, null=False, blank=False) start_date = models.DateTimeField( 'Start Date and Time', default=timezone_today, help_text= 'Event start date and time. Use 24h format for the time if needed.') end_date = models.DateTimeField('End Date and Time', blank=False, null=False, help_text='Event end date and time') location = models.CharField(max_length=400, blank=True, null=True) description = models.CharField(max_length=800, blank=True, null=True) score = models.DecimalField( max_digits=2, decimal_places=0, max_length=2, null=True, blank=True, help_text='The score according to the event score matrix') unit = models.ForeignKey(Unit, blank=False, null=False, on_delete=models.PROTECT) resources = models.CharField(max_length=400, blank=True, null=True, help_text="Resources needed for this event.") cost = models.DecimalField(blank=True, null=True, max_digits=8, decimal_places=2, help_text="Cost of this event") hidden = models.BooleanField(default=False, null=False, blank=False, editable=False) notes = models.CharField(max_length=400, blank=True, null=True, help_text='Special notes to registrants. These ' '*will* be displayed on the registration ' 'forms.') email = models.EmailField( 'Contact e-mail', null=True, blank=True, help_text= 'Contact email. Address that will be given to registrants on the registration ' 'success page in case they have any questions/problems.') closed = models.BooleanField( 'Close Registration', default=False, help_text= 'If this box is checked, people will not be able to register for this ' 'event even if it is still current.') registration_cap = models.PositiveIntegerField( null=True, blank=True, help_text='If you set a registration cap, people will not be allowed' ' to register after you have reached this many ' 'registrations marked as attending.') registration_email_text = models.TextField( null=True, blank=True, help_text='If you fill this in, this will be sent as an email to all ' 'all new registrants as a registration confirmation.') enable_waitlist = models.BooleanField( default=False, help_text='If this box is checked and you have a registration' ' cap which has been met, people will still be able ' 'to register but will be marked as not attending ' 'and waitlisted.') show_dietary_question = models.BooleanField( default=True, help_text='If this box is checked, the registration ' 'forms will show the dietary restrictions ' 'question. Otherwise, it\'ll be omitted.') config = JSONField(null=False, blank=False, default=dict) # 'extra_questions': additional questions to ask registrants extra_questions = config_property('extra_questions', []) objects = EventQuerySet.as_manager() def autoslug(self): return make_slug(self.unit.slug + '-' + self.title + '-' + str(self.start_date.date())) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique=True) def __str__(self): return "%s - %s - %s" % (self.title, self.unit.label, self.start_date) def delete(self): """Like most of our objects, we don't want to ever really delete it.""" self.hidden = True self.save() def current(self): """ Find out if an event is still current. Otherwise, we shouldn't be able to register for it. """ return self.start_date > timezone_today( ) or self.end_date >= timezone_today() # TODO add copy method to copy from one event to another def registration_count(self): return OutreachEventRegistration.objects.attended_event(self).count() def can_register(self): if self.closed or not self.current(): return False # If we allow a waitlist, and the event isn't closed, allow registration regardless of cap. if self.enable_waitlist: return True if self.registration_cap and self.registration_cap <= self.registration_count( ): return False return True def registrations_waitlisted(self): # If the event has passed, or registrations are closed, you're still hooped. if self.closed or not self.current(): return False # Otherwise, check to see if we're in waitlisted status. if self.enable_waitlist and self.registration_cap and self.registration_cap <= self.registration_count( ): return True return False
class OutreachEventRegistration(models.Model): """ An event registration. Only outreach admins should ever need to see this. Ideally, the users (not loggedin) should only ever have to fill in these once, but we'll add a UUID in case we need to send them back to them. """ # Don't make the UUID the primary key. We need a primary key that can be cast to an int for our logger. uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, null=False) last_name = models.CharField("Participant Last Name", max_length=32) first_name = models.CharField("Participant First Name", max_length=32) middle_name = models.CharField("Participant Middle Name", max_length=32, null=True, blank=True) age = models.DecimalField("Participant Age", null=True, blank=True, max_digits=2, decimal_places=0) birthdate = models.DateField("Participant Date of Birth", null=False, blank=False) parent_name = models.CharField(max_length=100, blank=False, null=False) parent_phone = models.CharField(max_length=15, blank=False, null=False) email = models.EmailField("Contact E-mail") event = models.ForeignKey(OutreachEvent, blank=False, null=False, on_delete=models.PROTECT) photo_waiver = models.BooleanField( "I, the parent or guardian of the Child, hereby authorize the Faculty of " "Applied Sciences (FAS) Outreach program of Simon Fraser University to " "photograph, audio record, video record, podcast and/or webcast the Child " "(digitally or otherwise) without charge; and to allow the FAS Outreach Program " "to copy, modify and distribute in print and online, those images that include " "my child in whatever appropriate way either the FAS Outreach Program and/or " "SFU sees fit without having to seek further approval. No names will be used in " "association with any images or recordings.", help_text="Check this box if you agree with the photo waiver.", default=False) participation_waiver = models.BooleanField( "I, the parent or guardian of the Child, agree to HOLD HARMLESS AND " "INDEMNIFY the FAS Outreach Program and SFU for any and all liability " "to which the University has no legal obligation, including but not " "limited to, any damage to the property of, or personal injury to my " "child or for injury and/or property damage suffered by any third party " "resulting from my child's actions whilst participating in the program. " "By signing this consent, I agree to allow SFU staff to provide or " "cause to be provided such medical services as the University or " "medical personnel consider appropriate. The FAS Outreach Program " "reserves the right to refuse further participation to any participant " "for rule infractions.", help_text="Check this box if you agree with the participation waiver.", default=False) previously_attended = models.BooleanField( "I have previously attended this event", default=False, help_text='Check here if you have attended this event in the past') school = models.CharField("Participant School", null=False, blank=False, max_length=200) grade = models.PositiveSmallIntegerField("Participant Grade", blank=False, null=False) hidden = models.BooleanField(default=False, null=False, blank=False, editable=False) notes = models.CharField("Allergies/Dietary Restrictions", max_length=400, blank=True, null=True) objects = OutreachEventRegistrationQuerySet.as_manager() created_at = models.DateTimeField(auto_now_add=True, editable=False) last_modified = models.DateTimeField(editable=False, blank=False, null=False) attended = models.BooleanField(default=True, editable=False, blank=False, null=False) waitlisted = models.BooleanField(default=False, editable=False, blank=False, null=False) config = JSONField(null=False, blank=False, default=dict) # 'extra_questions' - a dictionary of answers to extra questions. {'How do you feel?': 'Pretty sharp.'} extra_questions = config_property('extra_questions', []) email_sent = config_property('email_sent', '') def __str__(self): return "%s, %s = %s" % (self.last_name, self.first_name, self.event) def delete(self): """Like most of our objects, we don't want to ever really delete it.""" self.hidden = True self.save() def fullname(self): return "%s, %s %s" % (self.last_name, self.first_name, self.middle_name or '') def save(self, *args, **kwargs): self.last_modified = timezone.now() super(OutreachEventRegistration, self).save(*args, **kwargs) def email_memo(self): """ Emails the registration confirmation email if there is one and if none has been emailed for this registration before. """ # If this registration is waitlisted, don't email! if self.waitlisted: return if 'email' not in self.config and self.event.registration_email_text: subject = 'Registration Confirmation' from_email = settings.DEFAULT_FROM_EMAIL content = self.event.registration_email_text msg = EmailMultiAlternatives( subject, content, from_email, [self.email], headers={'X-coursys-topic': 'outreach'}) msg.send() self.email_sent = content self.save()
class AdvisorVisit(models.Model): """ Record of a visit to an advisor. We expect to record at least one of (1) the student/nonstudent, or (2) the unit where the student's program lives. The idea is that advisors can go to the student advising record and click "visited", or more generically click "a CMPT student visited". Only (1) is implemented in the frontend for now. Update: They don't seem to really want (2), so that's mainly unreachable right now. """ student = models.ForeignKey(Person, help_text='The student that visited the advisor', on_delete=models.PROTECT, blank=True, null=True, related_name='+') nonstudent = models.ForeignKey(NonStudent, blank=True, null=True, on_delete=models.PROTECT, help_text='The non-student that visited') program = models.ForeignKey(Unit, help_text='The unit of the program the student is in', blank=True, null=True, on_delete=models.PROTECT, related_name='+') advisor = models.ForeignKey(Person, help_text='The advisor that created the note', on_delete=models.PROTECT, editable=False, related_name='+') created_at = models.DateTimeField(default=datetime.datetime.now) end_time = models.DateTimeField(null=True, blank=True) campus = models.CharField(null=False, blank=False, choices=ADVISING_CAMPUS_CHOICES, max_length=5) categories = models.ManyToManyField(AdvisorVisitCategory, blank=True) unit = models.ForeignKey(Unit, help_text='The academic unit that owns this visit', null=False, on_delete=models.PROTECT) version = models.PositiveSmallIntegerField(null=False, blank=False, default=0, editable=False) hidden = models.BooleanField(null=False, blank=False, default=False, editable=False) config = JSONField(null=False, blank=False, default=dict) # addition configuration stuff: programs = config_property('programs', '') cgpa = config_property('cgpa', '') credits = config_property('credits', '') gender = config_property('gender', '') citizenship = config_property('citizenship', '') objects = AdvisorVisitQuerySet.as_manager() def autoslug(self): return make_slug(self.unit.slug + '-' + self.get_userid() + '-' + self.advisor.userid) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique=True) def save(self, *args, **kwargs): # ensure we always have either the student, nonstudent, or program unit. assert self.student or self.nonstudent or self.program assert not (self.student and self.nonstudent) super(AdvisorVisit, self).save(*args, **kwargs) # Template display helper methods def categories_display(self): return '; '.join(c.label for c in self.categories.all()) def has_categories(self): return self.categories.all().count() > 0 def get_userid(self): if self.student: return self.student.userid_or_emplid() else: return self.nonstudent.slug or 'none' def get_full_name(self): if self.student: return self.student.name() else: return self.nonstudent.name() def get_duration(self): if self.end_time: return str(self.end_time - self.created_at).split('.')[0] else: return None def has_sims_data(self): return self.programs or self.cgpa or self.credits or self.gender or self.citizenship def get_email(self): if self.student: return self.student.email() else: return self.nonstudent.email() def get_created_at_display(self): return self.created_at.strftime("%Y/%m/%d %H:%M") def get_end_time_display(self): if self.end_time: return self.end_time.strftime("%Y/%m/%d %H:%M") else: return ''
class QuestionVersion(models.Model): class VersionStatusManager(models.Manager): def get_queryset(self): return super().get_queryset().select_related('question').filter( status='V') question = models.ForeignKey(Question, on_delete=models.PROTECT, related_name='versions') status = models.CharField(max_length=1, null=False, blank=False, default='V', choices=STATUS_CHOICES) created_at = models.DateTimeField(default=datetime.datetime.now, null=False, blank=False) # used for ordering config = JSONField(null=False, blank=False, default=dict) # .config['text']: question as (text, markup, math:bool) # .config['marking']: marking notes as (text, markup, math:bool) # .config['review']: review notes as (text, markup, math:bool) # others as set by the .question.type (and corresponding QuestionType) text = config_property('text', default=('', DEFAULT_QUIZ_MARKUP, False)) marking = config_property('marking', default=('', DEFAULT_QUIZ_MARKUP, False)) review = config_property('review', default=('', DEFAULT_QUIZ_MARKUP, False)) objects = VersionStatusManager() class Meta: ordering = ['question', 'created_at', 'id'] def helper(self, question: Optional['Question'] = None): return QUESTION_HELPER_CLASSES[self.question.type](version=self, question=question) def export(self) -> Dict[str, Any]: return self.config @classmethod def select( cls, quiz: Quiz, questions: Iterable[Question], student: Optional[Member], answers: Optional[Iterable['QuestionAnswer']] ) -> List['QuestionVersion']: """ Build a (reproducibly-random) set of question versions. Honour the versions already answered, if instructor has been fiddling with questions during the quiz. """ assert (student is None and answers is None) or ( student is not None and answers is not None), 'must give current answers if student is known.' if student: rand = quiz.random_generator(str(student.id)) all_versions = QuestionVersion.objects.filter(question__in=questions) version_lookup = { q_id: list(vs) for q_id, vs in itertools.groupby(all_versions, key=lambda v: v.question_id) } if answers is not None: answers_lookup = {a.question_id: a for a in answers} versions = [] for q in questions: vs = version_lookup[q.id] if student: # student: choose randomly unless they have already answered a version # We need to call rand.next() here to update the state of the LCG, even if we have something # in answers_lookup n = rand.next(len(vs)) if q.id in answers_lookup: ans = answers_lookup[q.id] v = ans.question_version try: v.choice = vs.index(v) + 1 except ValueError: # Happens if a student answers a version, but then the instructor deletes it. Hopefully never. v.choice = 0 else: v = vs[n] v.choice = n + 1 else: # instructor preview: choose the first v = vs[0] v.choice = 1 v.n_versions = len(vs) versions.append(v) return versions def question_html(self) -> SafeText: """ Markup for the question itself """ helper = self.helper() return helper.question_html() def question_preview_html(self) -> SafeText: """ Markup for an instructor's preview of the question (e.g. question + MC options) """ helper = self.helper() return helper.question_preview_html() def entry_field(self, student: Optional[Member], questionanswer: 'QuestionAnswer' = None): helper = self.helper() if questionanswer: assert questionanswer.question_version_id == self.id return helper.get_entry_field(questionanswer=questionanswer, student=student) def entry_head_html(self) -> SafeText: """ Markup this version needs inserted into the <head> on the question page. """ helper = self.helper() return helper.entry_head_html() def marking_html(self) -> SafeText: text, markup, math = self.marking return markup_to_html(text, markup, math=math) def review_html(self) -> SafeText: text, markup, math = self.review return markup_to_html(text, markup, math=math) def automark_all( self, activity_components: Dict['Question', ActivityComponent] ) -> Iterable[Tuple[Member, ActivityComponentMark]]: """ Automark everything for this version, if possible. Return Student/ActivityComponentMark pairs that need to be saved with an appropriate StudentActivityMark """ helper = self.helper(question=self.question) if not helper.auto_markable: # This helper can't do automarking, so don't try. return try: component = activity_components[self.question] except KeyError: raise MarkingNotConfiguredError() answers = QuestionAnswer.objects.filter( question_version=self).select_related('question', 'student') for a in answers: mark = helper.automark(a) if mark is None: # helper is allowed to throw up its hands and return None if auto-marking not possible continue # We have a mark and comment: create a ActivityComponentMark for it points, comment = mark member = a.student comp_mark = ActivityComponentMark(activity_component=component, value=points, comment=comment) yield member, comp_mark
class Quiz(models.Model): class QuizStatusManager(models.Manager): def get_queryset(self): return super().get_queryset().select_related( 'activity', 'activity__offering').filter(status='V') activity = models.OneToOneField(Activity, on_delete=models.PROTECT) start = models.DateTimeField( help_text= 'Quiz will be visible to students after this time. Time format: HH:MM:SS, 24-hour time' ) end = models.DateTimeField( help_text= 'Quiz will be invisible to students and unsubmittable after this time. Time format: HH:MM:SS, 24-hour time' ) status = models.CharField(max_length=1, null=False, blank=False, default='V', choices=STATUS_CHOICES) config = JSONField(null=False, blank=False, default=dict) # addition configuration stuff: # .config['grace']: length of grace period at the end of the exam (in seconds) # .config['intro']: introductory text for the quiz # .config['markup']: markup language used: see courselib/markup.py # .config['math']: intro uses MathJax? (boolean) # .config['secret']: the "secret" used to seed the randomization for this quiz (integer) # .config['honour_code']: do we make the student agree to the honour code for this quiz? (boolean) # .config['photos']: do we capture verification images for this quiz? (boolean) # .config['reviewable']: defunct. Now maps to True -> .review == 'all'; False -> .review == 'none' # .config['review']: can students review, and what can they see? REVIEW_CHOICES gives options. # .config['honour_code_text']: markup of the honour code # .config['honour_code_markup']: honour code markup language # .config['honour_code_math']: honour code uses MathJax? (boolean) grace = config_property('grace', default=300) intro = config_property('intro', default='') markup = config_property('markup', default=DEFAULT_QUIZ_MARKUP) math = config_property('math', default=False) secret = config_property('secret', default='not a secret') honour_code = config_property('honour_code', default=True) honour_code_text = config_property('honour_code_text', default=HONOUR_CODE_DEFAULT) honour_code_markup = config_property('honour_code_markup', default=DEFAULT_QUIZ_MARKUP) honour_code_math = config_property('honour_code_math', default=False) photos = config_property('photos', default=False) #review = config_property('reviewable', default='none') # special-cased below # Special handling to honour .config['reviewable'] if it was set before .config['review'] existed. def _review_get(self): if 'review' in self.config: return self.config['review'] elif 'reviewable' in self.config: return 'all' if self.config['reviewable'] else 'none' else: return 'none' def _review_set(self, val): if 'reviewable' in self.config: del self.config['reviewable'] self.config['review'] = val review = property(_review_get, _review_set) # .config fields allowed in the JSON import ALLOWED_IMPORT_CONFIG = { 'grace', 'honour_code', 'photos', 'reviewable', 'review' } class Meta: verbose_name_plural = 'Quizzes' objects = QuizStatusManager() def get_absolute_url(self): return resolve_url('offering:quiz:index', course_slug=self.activity.offering.slug, activity_slug=self.activity.slug) def save(self, *args, **kwargs): res = super().save(*args, **kwargs) if 'secret' not in self.config: # Ensure we are saved (so self.id is filled), and if the secret isn't there, fill it in. self.config['secret'] = string_hash(settings.SECRET_KEY) + self.id super().save(*args, **kwargs) return res def get_start_end( self, member: Optional[Member] ) -> Tuple[datetime.datetime, datetime.datetime]: """ Get the start and end times for this quiz. The start/end may have been overridden by the instructor for this student, but default to .start and .end if not """ if not member: # in the generic case, use the defaults return self.start, self.end special_case = TimeSpecialCase.objects.filter(quiz=self, student=member).first() if not special_case: # no special case for this student return self.start, self.end else: # student has a special case return special_case.start, special_case.end def get_starts_ends( self, members: Iterable[Member] ) -> Dict[Member, Tuple[datetime.datetime, datetime.datetime]]: """ Get the start and end times for this quiz for each member. """ special_cases = TimeSpecialCase.objects.filter( quiz=self, student__in=members).select_related('student') sc_lookup = {sc.student: sc for sc in special_cases} # stub so we can always get a TimeSpecialCase in the comprehension below default = TimeSpecialCase(start=self.start, end=self.end) return { m: (sc_lookup.get(m, default).start, sc_lookup.get(m, default).end) for m in members } def ongoing(self, member: Optional[Member] = None) -> bool: """ Is the quiz currently in-progress? """ start, end = self.get_start_end(member=member) if not start or not end: # newly created with start and end not yet filled return False now = datetime.datetime.now() return start <= now <= end def completed(self, member: Optional[Member] = None) -> bool: """ Is the quiz over? """ _, end = self.get_start_end(member=member) if not end: # newly created with end not yet filled return False now = datetime.datetime.now() return now > end def intro_html(self) -> SafeText: return markup_to_html(self.intro, markuplang=self.markup, math=self.math) def honour_code_html(self) -> SafeText: return markup_to_html(self.honour_code_text, markuplang=self.honour_code_markup, math=self.honour_code_math) def random_generator(self, seed: str) -> Randomizer: """ Return a "random" value generator with given seed, which must be deterministic so we can reproduce the values. """ seed_str = str(self.secret) + '--' + seed return Randomizer(seed_str) @transaction.atomic def configure_marking(self, delete_others=True): """ Configure the rubric-based marking to be quiz marks. """ if not self.activity.quiz_marking(): self.activity.set_quiz_marking(True) self.activity.save() num_activity = NumericActivity.objects.get(id=self.activity_id) total = 0 all_components = ActivityComponent.objects.filter( numeric_activity=num_activity) questions = self.question_set.all() i = 0 for i, q in enumerate(questions): existing_comp = [ c for c in all_components if c.config.get('quiz-question-id', None) == q.id ] if existing_comp: component = existing_comp[0] else: component = ActivityComponent(numeric_activity=num_activity) component.position = i + 1 component.max_mark = q.points component.title = 'Question #%i' % (i + 1, ) # component.description = '' # if instructor has entered a description, let it stand component.deleted = False component.config['quiz-question-id'] = q.id component.save() component.used = True total += q.points pos = i + 2 for c in all_components: if hasattr(c, 'used') and c.used: continue else: if delete_others or c.config.get('quiz-question-id', None): # delete other components if requested, and always delete other quiz-created components c.deleted = True else: # or reorder to the bottom if not c.position = pos pos += 1 if not c.deleted: total += c.max_mark c.save() old_max = num_activity.max_grade if old_max != total: num_activity.max_grade = total num_activity.save() def activitycomponents_by_question( self) -> Dict['Question', ActivityComponent]: """ Build dict to map Question to corresponding ActivityComponent in marking. """ questions = self.question_set.all() components = ActivityComponent.objects.filter( numeric_activity_id=self.activity_id, deleted=False) question_lookup = {q.id: q for q in questions} component_lookup = {} for c in components: if 'quiz-question-id' in c.config and c.config[ 'quiz-question-id'] in question_lookup: q = question_lookup[c.config['quiz-question-id']] component_lookup[q] = c return component_lookup @transaction.atomic() def automark_all(self, user: User) -> int: """ Fill in marking for any QuestionVersions that support it. Return number marked. """ versions = QuestionVersion.objects.filter( question__quiz=self, question__status='V').select_related('question') activity_components = self.activitycomponents_by_question() member_component_results = [ ] # : List[Tuple[Member, ActivityComponentMark]] for v in versions: member_component_results.extend( v.automark_all(activity_components=activity_components)) # Now the ugly work: combine the just-automarked components with any existing manual marking, and save... old_sam_lookup = { # dict to find old StudentActivityMarks sam.numeric_grade.member: sam for sam in StudentActivityMark.objects.filter( activity=self.activity).order_by('created_at').select_related( 'numeric_grade__member').prefetch_related( 'activitycomponentmark_set') } # dict to find old ActivityComponentMarks old_acm_by_component_id = defaultdict( dict) # : Dict[int, Dict[Member, ActivityComponentMark]] old_sam = StudentActivityMark.objects.filter(activity=self.activity).order_by('created_at') \ .select_related('numeric_grade__member').prefetch_related('activitycomponentmark_set') for sam in old_sam: for acm in sam.activitycomponentmark_set.all(): old_acm_by_component_id[acm.activity_component_id][ sam.numeric_grade.member] = acm numeric_grade_lookup = { # dict to find existing NumericGrades ng.member: ng for ng in NumericGrade.objects.filter( activity=self.activity).select_related('member') } all_components = set( ActivityComponent.objects.filter( numeric_activity_id=self.activity_id, deleted=False)) member_component_results.sort( key=lambda pair: pair[0].id) # ... get Members grouped together n_marked = 0 for member, member_acms in itertools.groupby(member_component_results, lambda pair: pair[0]): # Get a NumericGrade to work with try: ngrade = numeric_grade_lookup[member] except KeyError: ngrade = NumericGrade(activity_id=self.activity_id, member=member, flag='NOGR') ngrade.save(newsitem=False, entered_by=None, is_temporary=True) # Create ActivityMark to save under am = StudentActivityMark(numeric_grade=ngrade, activity_id=self.activity_id, created_by=user.username) old_am = old_sam_lookup.get(member) if old_am: am.overall_comment = old_am.overall_comment am.late_penalty = old_am.late_penalty am.mark_adjustment = old_am.mark_adjustment am.mark_adjustment_reason = old_am.mark_adjustment_reason am.save() # Find/create ActivityComponentMarks for each component auto_acm_lookup = { acm.activity_component: acm for _, acm in member_acms } any_missing = False acms = [] for c in all_components: # For each ActivityComponent, find one of # (1) just-auto-marked ActivityComponentMark, # (2) ActivityComponentMark from previous manual marking, # (3) nothing. if c in auto_acm_lookup: # (1) acm = auto_acm_lookup[c] acm.activity_mark = am n_marked += 1 elif c.id in old_acm_by_component_id and member in old_acm_by_component_id[ c.id]: # (2) old_acm = old_acm_by_component_id[c.id][member] acm = ActivityComponentMark(activity_mark=am, activity_component=c, value=old_acm.value, comment=old_acm.comment) else: # (3) acm = ActivityComponentMark(activity_mark=am, activity_component=c, value=None, comment=None) any_missing = True acm.save() acms.append(acm) if not any_missing: total = am.calculated_mark(acms) ngrade.value = total ngrade.flag = 'GRAD' am.mark = total else: ngrade.value = 0 ngrade.flag = 'NOGR' am.mark = None ngrade.save(newsitem=False, entered_by=user.username) am.save() return n_marked def export(self) -> Dict[str, Any]: config = { 'grace': self.grace, 'honour_code': self.honour_code, 'photos': self.photos, 'review': self.review, } intro = [self.intro, self.markup, self.math] questions = [q.export() for q in self.question_set.all()] return { 'config': config, 'intro': intro, 'questions': questions, }
class NewsItem(models.Model): """ Class representing a news item for a particular user. """ user = models.ForeignKey(Person, null=False, related_name="user", on_delete=models.PROTECT) author = models.ForeignKey(Person, null=True, related_name="author", on_delete=models.PROTECT) course = models.ForeignKey(CourseOffering, null=True, on_delete=models.PROTECT) source_app = models.CharField(max_length=20, null=False, help_text="Application that created the story") title = models.CharField(max_length=100, null=False, help_text="Story title (plain text)") content = models.TextField(help_text=mark_safe('Main story content')) published = models.DateTimeField(default=datetime.datetime.now) updated = models.DateTimeField(auto_now=True) url = models.URLField(blank=True, verbose_name="URL", help_text='absolute URL for the item: starts with "http://" or "/"') read = models.BooleanField(default=False, help_text="The user has marked the story read") config = JSONField(null=False, blank=False, default=dict) # addition configuration stuff: # 'markup': markup language used: see courselib/markup.py # 'math': page uses MathJax? (boolean) markup = config_property('markup', 'creole') math = config_property('math', False) def __str__(self): return '"%s" for %s' % (self.title, self.user.userid) def save(self, *args, **kwargs): super(NewsItem, self).save(*args, **kwargs) # see if this user wants news by email ucs = UserConfig.objects.filter(user=self.user, key="newsitems") if ucs and 'email' in ucs[0].value and not ucs[0].value['email']: # user has requested no email pass else: self.email_user() def email_from(self): """ Determine who the email should appear to come from: perfer to use course contact email if exists. """ if self.course and self.course.taemail(): if self.author: return "%s <%s> (per %s)" % (self.course.name(), self.course.taemail(), self.author.name()) else: return "%s <%s>" % (self.course.name(), self.course.taemail()) elif self.author: return self.author.full_email() else: return settings.DEFAULT_FROM_EMAIL # turn the source_app field into a more externally-friendly string source_app_translate = { 'dashboard': 'typed', 'group submission': 'submit_group', } def source_display(self): if self.source_app in self.source_app_translate: return self.source_app_translate[self.source_app] else: return self.source_app def email_user(self): """ Email this news item to the user. """ if not self.user.email(): return headers = { 'Precedence': 'bulk', 'Auto-Submitted': 'auto-generated', 'X-coursys-topic': self.source_display(), } if self.course: subject = "%s: %s" % (self.course.name(), self.title) headers['X-course'] = self.course.slug else: subject = self.title to_email = self.user.full_email() from_email = self.email_from() if self.author: headers['Sender'] = self.author.email() else: headers['Sender'] = settings.DEFAULT_SENDER_EMAIL if self.url: url = self.absolute_url() else: url = settings.BASE_ABS_URL + reverse('news:news_list') text_content = "For more information, see " + url + "\n" text_content += "\n--\nYou received this email from %s. If you do not wish to receive\nthese notifications by email, you can edit your email settings here:\n " % (product_name(hint='course')) text_content += settings.BASE_ABS_URL + reverse('config:news_config') if self.course: html_content = '<h3>%s: <a href="%s">%s</a></h3>\n' % (self.course.name(), url, self.title) else: html_content = '<h3><a href="%s">%s</a></h3>\n' % (url, self.title) html_content += self.content_xhtml() html_content += '\n<p style="font-size: smaller; border-top: 1px solid black;">You received this email from %s. If you do not wish to receive\nthese notifications by email, you can <a href="%s">change your email settings</a>.</p>' \ % (product_name(hint='course'), settings.BASE_ABS_URL + reverse('config:news_config')) msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email], headers=headers) msg.attach_alternative(html_content, "text/html") msg.send() def content_xhtml(self): """ Render content field as XHTML. """ from courselib.markup import markup_to_html return markup_to_html(self.content, self.markup, html_already_safe=False, restricted=True) def rfc_updated(self): """ Format the updated time in RFC3339 format """ tz = timezone(settings.TIME_ZONE) dt = self.updated offset = tz.utcoffset(dt) return _rfc_format(dt-offset) def feed_id(self): """ Return a unique to serve as a unique Atom identifier (after being appended to the server URL). """ return slugify( "%s %s %s" % (self.user.userid, self.published.strftime("%Y%m%d-%H%M%S"), self.id) ) def absolute_url(self): """ Return an absolute URL (scheme+server+path) for this news item. """ if self.url.startswith("/"): return settings.BASE_ABS_URL + self.url else: return self.url @classmethod def for_members(cls, member_kwargs, newsitem_kwargs): """ Create a news item for Members identified by member_kwards (role=DROP excluded automatically). Details of Newsitem (except person) should be specified by newsitem_kwargs. """ # randomize order in the hopes of throwing off any spam filters members = Member.objects.exclude(role="DROP").exclude(role="APPR").filter(**member_kwargs) members = list(members) random.shuffle(members) markup = newsitem_kwargs.pop('markup', 'textile') for m in members: n = NewsItem(user=m.person, **newsitem_kwargs) n.markup = markup n.save()
class RARequest(models.Model): person = models.ForeignKey(Person, related_name='rarequest_person', on_delete=models.PROTECT, null=True) nonstudent = models.BooleanField(default=False) # only needed if no ID for person first_name = models.CharField(max_length=32, null=True, blank=True) last_name = models.CharField(max_length=32, null=True, blank=True) email_address = models.EmailField(max_length=80, null=True, blank=True) unit = models.ForeignKey(Unit, null=False, blank=False, on_delete=models.PROTECT) # submitter is not always the same as person created the request author = models.ForeignKey(Person, related_name='rarequest_author', on_delete=models.PROTECT, editable=False) supervisor = models.ForeignKey(Person, related_name='rarequest_supervisor', on_delete=models.PROTECT) config = JSONField(null=False, blank=False, default=dict) # student information student = config_property('student', default='') coop = config_property('coop', default='') mitacs = config_property('mitacs', default='') thesis = config_property('thesis', default='') # comments about supervisor or appointee people_comments = config_property('people_comments', default='') # hiring category is based on the above student information hiring_category = models.CharField(max_length=80, default=None, choices=REQUEST_HIRING_CATEGORY) # funding sources fs1_unit = config_property('fs1_unit', default='') fs1_fund = config_property('fs1_fund', default='') fs1_project = config_property('fs1_project', default='') fs1_percentage = config_property('fs1_percentage', default=100) fs2_option = config_property('fs2_unit', default=False) fs2_unit = config_property('fs2_unit', default='') fs2_fund = config_property('fs2_fund', default='') fs2_project = config_property('fs2_project', default='') fs2_percentage = config_property('fs2_percentage', default=0) fs3_option = config_property('fs3_unit', default=False) fs3_unit = config_property('fs3_unit', default='') fs3_fund = config_property('fs3_fund', default='') fs3_project = config_property('fs3_project', default='') fs3_percentage = config_property('fs3_percentage', default=0) # start and end dates start_date = models.DateField(auto_now=False, default=datetime.date.today, auto_now_add=False) end_date = models.DateField(auto_now=False, default=datetime.date.today, auto_now_add=False) # payment methods gras_payment_method = config_property('gras_payment_method', default='') ra_payment_method = config_property('ra_payment_method', default='') # payment inputs (4 types: RA (BW), RA (H), GRAS(LS), GRAS(BW)) # RA + BW -> total_gross, weeks_vacation, biweekly_hours... should then calculate biweekly_salary and gross_hourly rabw_total_gross = config_property('rabw_total_gross', default=0) rabw_weeks_vacation = config_property('rabw_weeks_vacation', default=0) rabw_biweekly_hours = config_property('rabw_biweekly_hours', default=0) # should also calculate... rabw_biweekly_salary = config_property('rabw_biweekly_salary', default=0) rabw_gross_hourly = config_property('rabw_gross_hourly', default=0) # RA + H -> gross_hourly, vacation_pay, biweekly_hours rah_gross_hourly = config_property('rah_gross_hourly', default=0) rah_vacation_pay = config_property('rah_vacation_pay', default=0) rah_biweekly_hours = config_property('rah_biweekly_hours', default=0) # GRAS + LS -> total gross grasls_total_gross = config_property('grasls_total_gross', default=0) # GRAS + BW -> total_gross, biweekly_hours... should then calculate biweekly_salary, gross_salary grasbw_total_gross = config_property('grasbw_total_gross', default=0) grasbw_biweekly_hours = config_property('grasbw_biweekly_hours', default=0) # should also calculate... grasbw_biweekly_salary = config_property('grasbw_biweekly_salary', default=0) grasbw_gross_hourly = config_property('grasbw_gross_hourly', default=0) # all payment methods need to calculate total pay total_pay = models.DecimalField(max_digits=8, decimal_places=2) # file attachments file_attachment_1 = models.FileField( storage=UploadedFileStorage, null=True, upload_to=ra_request_attachment_upload_to, blank=True, max_length=500) file_mediatype_1 = models.CharField(null=True, blank=True, max_length=200, editable=False) file_attachment_2 = models.FileField( storage=UploadedFileStorage, null=True, upload_to=ra_request_attachment_upload_to, blank=True, max_length=500) file_mediatype_2 = models.CharField(null=True, blank=True, max_length=200, editable=False) # funding comments funding_comments = config_property('funding_comments', default='') # ra only options ra_benefits = config_property('ra_benefits', default='') ra_duties_ex = models.CharField(blank=True, null=True, max_length=500) ra_duties_dc = models.CharField(blank=True, null=True, max_length=500) ra_duties_pd = models.CharField(blank=True, null=True, max_length=500) ra_duties_im = models.CharField(blank=True, null=True, max_length=500) ra_duties_eq = models.CharField(blank=True, null=True, max_length=500) ra_duties_su = models.CharField(blank=True, null=True, max_length=500) ra_duties_wr = models.CharField(blank=True, null=True, max_length=500) ra_duties_pm = models.CharField(blank=True, null=True, max_length=500) ra_other_duties = config_property('ra_other_duties', default='') # admin funding_available = config_property('funding_available', default=False) grant_active = config_property('grant_active', default=False) salary_allowable = config_property('salary_allowable', default=False) supervisor_check = config_property('supervisor_check', default=False) visa_valid = config_property('visa_valid', default=False) payroll_collected = config_property('payroll_collected', default=False) paf_signed = config_property('paf_signed', default=False) admin_notes = config_property('admin_notes', default='') # creation, deletion and status created_at = models.DateTimeField(auto_now_add=True) deleted = models.BooleanField(null=False, default=False) complete = models.BooleanField(null=False, default=False) def get_complete(self): if self.funding_available and self.grant_active and self.salary_allowable and self.supervisor_check and self.visa_valid and self.payroll_collected and self.paf_signed: return True else: return False # slugs def autoslug(self): if self.nonstudent: ident = self.first_name + '_' + self.last_name else: if self.person: if self.person.userid: ident = self.person.userid else: ident = str(self.person.emplid) return make_slug('request' + '-' + str(self.start_date.year) + '-' + ident) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique=True) def split_duties(self, duties): split = (duties).replace("[", '').replace("]", '').replace("'", '') if split != '': split = list(map(int, split.split(','))) else: split = [] return split def duties_list(self): duties = [] duties += [ duty for val, duty in DUTIES_CHOICES_EX if val in self.split_duties_ex() ] duties += [ duty for val, duty in DUTIES_CHOICES_DC if val in self.split_duties_dc() ] duties += [ duty for val, duty in DUTIES_CHOICES_PD if val in self.split_duties_pd() ] duties += [ duty for val, duty in DUTIES_CHOICES_IM if val in self.split_duties_im() ] duties += [ duty for val, duty in DUTIES_CHOICES_EQ if val in self.split_duties_eq() ] duties += [ duty for val, duty in DUTIES_CHOICES_SU if val in self.split_duties_su() ] duties += [ duty for val, duty in DUTIES_CHOICES_WR if val in self.split_duties_wr() ] duties += [ duty for val, duty in DUTIES_CHOICES_PM if val in self.split_duties_pm() ] return duties def split_duties_ex(self): return self.split_duties(self.ra_duties_ex) def split_duties_dc(self): return self.split_duties(self.ra_duties_dc) def split_duties_pd(self): return self.split_duties(self.ra_duties_pd) def split_duties_im(self): return self.split_duties(self.ra_duties_im) def split_duties_eq(self): return self.split_duties(self.ra_duties_eq) def split_duties_su(self): return self.split_duties(self.ra_duties_su) def split_duties_wr(self): return self.split_duties(self.ra_duties_wr) def split_duties_pm(self): return self.split_duties(self.ra_duties_pm) def get_name(self): if self.first_name and self.last_name: name = self.first_name + " " + self.last_name if self.person: name = self.person.name() return name def get_absolute_url(self): return reverse('ra:view_request', kwargs={'ra_slug': self.slug}) def has_attachments(self): return self.attachments.visible().count() > 0
class OutreachEventRegistration(models.Model): """ An event registration. Only outreach admins should ever need to see this. Ideally, the users (not loggedin) should only ever have to fill in these once, but we'll add a UUID in case we need to send them back to them. """ # Don't make the UUID the primary key. We need a primary key that can be cast to an int for our logger. uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, null=False) last_name = models.CharField("Participant Last Name", max_length=32) first_name = models.CharField("Participant First Name", max_length=32) middle_name = models.CharField("Participant Middle Name", max_length=32, null=True, blank=True) age = models.DecimalField("Participant Age", null=True, blank=True, max_digits=2, decimal_places=0) parent_name = models.CharField(max_length=100, blank=False, null=False) parent_phone = models.CharField(max_length=15, blank=False, null=False) email = models.EmailField("Contact E-mail") event = models.ForeignKey(OutreachEvent, blank=False, null=False) photo_waiver = models.BooleanField("I, hereby authorize the Faculty of Applied Sciences Outreach program of Simon " "Fraser University to photograph, audio record, video record, podcast and/or " "webcast the Child (digitally or otherwise) without charge; and to allow the FAS" " Outreach Program to copy, modify and distribute in print and online, those " "images that include your child in whatever appropriate way either the FAS " "Outreach Program and/or SFU sees fit without having to seek further approval. " "No names will be used in association with any images or recordings.", help_text="Check this box if you agree with the photo waiver.", default=False) participation_waiver = models.BooleanField("I agree to HOLD HARMLESS AND INDEMNIFY the FAS Outreach Program and " "SFU for any and all liability to which the University has no legal " "obligation, including but not limited to, any damage to the property " "of, or personal injury to my child or for injury and/or property " "damage suffered by any third party resulting from my child's actions " "whilst participating in the program. By signing this consent, I agree " "to allow SFU staff to provide or cause to be provided such medical " "services as the University or medical personnel consider appropriate. " "The FAS Outreach Program reserves the right to refuse further " "participation to any participant for rule infractions.", help_text="Check this box if you agree with the participation waiver.", default=False) previously_attended = models.BooleanField("I have previously attended this event", default=False, help_text='Check here if you have attended this event in the past') school = models.CharField("Participant School", null=True, blank=True, max_length=200) grade = models.PositiveSmallIntegerField("Participant Grade", blank=False, null=False) hidden = models.BooleanField(default=False, null=False, blank=False, editable=False) notes = models.CharField("Allergies/Dietary Restrictions", max_length=400, blank=True, null=True) objects = OutreachEventRegistrationQuerySet.as_manager() created_at = models.DateTimeField(auto_now_add=True, editable=False) last_modified = models.DateTimeField(editable=False, blank=False, null=False) attended = models.BooleanField(default=True, editable=False, blank=False, null=False) config = JSONField(null=False, blank=False, default={}) # 'extra_questions' - a dictionary of answers to extra questions. {'How do you feel?': 'Pretty sharp.'} extra_questions = config_property('extra_questions', []) def __unicode__(self): return u"%s, %s = %s" % (self.last_name, self.first_name, self.event) def delete(self): """Like most of our objects, we don't want to ever really delete it.""" self.hidden = True self.save() def fullname(self): return u"%s, %s %s" % (self.last_name, self.first_name, self.middle_name or '') def save(self, *args, **kwargs): self.last_modified = timezone.now() super(OutreachEventRegistration, self).save(*args, **kwargs)
class Memo(models.Model): """ A memo created by the system, and attached to a CareerEvent. """ career_event = models.ForeignKey(CareerEvent, null=False, blank=False) unit = models.ForeignKey( Unit, null=False, blank=False, help_text= "The unit producing the memo: will determine the letterhead used for the memo." ) sent_date = models.DateField(default=datetime.date.today, help_text="The sending date of the letter") to_lines = models.TextField(verbose_name='Attention', help_text='Recipient of the memo', null=True, blank=True) cc_lines = models.TextField(verbose_name='CC lines', help_text='Additional recipients of the memo', null=True, blank=True) from_person = models.ForeignKey(Person, null=True, related_name='+') from_lines = models.TextField( verbose_name='From', help_text= 'Name (and title) of the sender, e.g. "John Smith, Applied Sciences, Dean"' ) subject = models.TextField( help_text= 'The subject of the memo (lines will be formatted separately in the memo header)' ) template = models.ForeignKey(MemoTemplate, null=True) memo_text = models.TextField(help_text="I.e. 'Congratulations on ... '") #salutation = models.CharField(max_length=100, default="To whom it may concern", blank=True) #closing = models.CharField(max_length=100, default="Sincerely") created_at = models.DateTimeField(auto_now_add=True) created_by = models.ForeignKey(Person, help_text='Letter generation requested by.', related_name='+') hidden = models.BooleanField(default=False) config = JSONField( default={}) # addition configuration for within the memo # XXX: 'use_sig': use the from_person's signature if it exists? # (Users set False when a real legal signature is required. use_sig = config_property('use_sig', default=True) def autoslug(self): if self.template: return make_slug(self.career_event.slug + "-" + self.template.label) else: return make_slug(self.career_event.slug + "-memo") slug = AutoSlugField(populate_from=autoslug, null=False, editable=False, unique_with=('career_event', )) def __unicode__(self): return u"%s memo for %s" % (self.template.label, self.career_event) def save(self, *args, **kwargs): # normalize text so it's easy to work with if not self.to_lines: self.to_lines = '' self.to_lines = normalize_newlines(self.to_lines.rstrip()) self.from_lines = normalize_newlines(self.from_lines.rstrip()) self.subject = normalize_newlines(self.subject.rstrip()) self.memo_text = normalize_newlines(self.memo_text.rstrip()) self.memo_text = many_newlines.sub('\n\n', self.memo_text) super(Memo, self).save(*args, **kwargs) def write_pdf(self, response): from dashboard.letters import OfficialLetter, MemoContents doc = OfficialLetter(response, unit=self.unit) l = MemoContents( to_addr_lines=self.to_lines.split("\n"), from_name_lines=self.from_lines.split("\n"), date=self.sent_date, subject=self.subject.split("\n"), cc_lines=self.cc_lines.split("\n"), ) content_lines = self.memo_text.split("\n\n") l.add_paragraphs(content_lines) doc.add_letter(l) doc.write()
class TAContract(models.Model): """ TA Contract, filled in by TA Administrator """ person = models.ForeignKey(Person, on_delete=models.PROTECT) category = models.ForeignKey(TACategory, on_delete=models.PROTECT, related_name="contract") status = models.CharField(max_length=4, choices=CONTRACT_STATUS_CHOICES, default="NEW", editable=False) sin = models.CharField(max_length=30, verbose_name="SIN", help_text="Social Insurance Number - 000000000 if unknown") # We want do add some sort of accountability for checking visas. Don't # allow printing of the contract if this box hasn't been checked. visa_verified = models.BooleanField(default=False, help_text="I have verified this TA's visa information") deadline_for_acceptance = models.DateField() appointment_start = models.DateField(null=True, blank=True) appointment_end = models.DateField(null=True, blank=True) pay_start = models.DateField() pay_end = models.DateField() payperiods = models.DecimalField(max_digits=4, decimal_places=2, verbose_name= "During the contract, how many bi-weekly pay periods?") appointment = models.CharField(max_length=4, choices=APPOINTMENT_CHOICES, default="INIT") conditional_appointment = models.BooleanField(default=False) tssu_appointment = models.BooleanField(default=True) # this is really only important if the contract is in Draft status # if Signed, the student's acceptance is implied. # if Cancelled, the student's acceptance doesn't matter. accepted_by_student = models.BooleanField(default=False, help_text="Has the student accepted the contract?") comments = models.TextField(blank=True) # curtis-lassam-2014-09-01 def autoslug(self): return make_slug(self.person.first_name + '-' + self.person.last_name \ + "-" + str(self.pay_start)) slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique=True) created = models.DateTimeField(auto_now_add=True, editable=False) created_by = models.CharField(max_length=20, null=False, blank=False, \ editable=False) config = JSONField(null=False, blank=False, editable=False, default=dict) objects = TAContractManager() rejected = config_property('rejected', False) def __str__(self): return "%s" % (self.person,) @property def frozen(self): """ Returns True when this contract is uneditable. """ return self.status != 'NEW' def save(self, always_allow=False, *args, **kwargs): if not always_allow and self.frozen: raise ContractFrozen() else: super(TAContract, self).save(*args, **kwargs) self.set_grad_student_sin() self.sync_course_member() def sign(self): """ Moves the contract from "Draft" to "Contract Signed" """ self.status = 'SGN' self.save(always_allow=True) def cancel(self): """ Moves the contract from "Contract Signed" to "Cancelled" or Moves the contract from "New" to *deleted* """ if self.frozen: self.status = 'CAN' self.save(always_allow=True) else: self.delete() def set_grad_student_sin(self): """ Sets the SIN of the GradStudent object, if it exists and hasn't already been set. """ for gs in GradStudent.objects.filter(person=self.person): if (('sin' not in gs.config or ('sin' in gs.config and gs.config['sin'] in DUMMY_SINS)) and not self.sin in DUMMY_SINS): gs.person.set_sin(self.sin) gs.person.save() def delete(self, *args, **kwargs): if self.frozen: raise ContractFrozen() else: for course in self.course.all(): course.delete() for receipt in self.email_receipt.all(): receipt.delete() for attachment in self.attachments.all(): attachment.delete() self.sync_course_member() super(TAContract, self).delete(*args, **kwargs) def copy(self, created_by): """ Return a copy of this contract, but with status="NEW" """ newcontract = TAContract(person=self.person, category=self.category, sin=self.sin, deadline_for_acceptance= \ self.deadline_for_acceptance, appointment_start=self.appointment_start, appointment_end=self.appointment_end, pay_start=self.pay_start, pay_end=self.pay_end, payperiods=self.payperiods, created_by=created_by, appointment=self.appointment, conditional_appointment= \ self.conditional_appointment, tssu_appointment=self.tssu_appointment, comments = self.comments) newcontract.save() for course in self.course.all(): newcourse = TACourse(course=course.course, contract=newcontract, bu=course.bu, labtut=course.labtut) newcourse.save() return newcontract @property def pay_per_bu(self): return self.category.pay_per_bu @property def scholarship_per_bu(self): return self.category.scholarship_per_bu @property def bu_lab_bonus(self): return self.category.bu_lab_bonus @property def bu(self): if len(self.course.all()) == 0: return decimal.Decimal(0) else: return sum( [course.bu for course in self.course.all()] ) @property def total_bu(self): if len(self.course.all()) == 0: return decimal.Decimal(0) else: return sum( [course.total_bu for course in self.course.all()] ) @property def total_pay(self): if len(self.course.all()) == 0: return decimal.Decimal(0) else: return decimal.Decimal(math.ceil(self.total_bu * self.pay_per_bu)) @property def biweekly_pay(self): if self.payperiods == 0: return decimal.Decimal(0) return self.total_pay / decimal.Decimal(self.payperiods) @property def scholarship_pay(self): if len(self.course.all()) == 0: return decimal.Decimal(0) else: return self.bu * self.scholarship_per_bu @property def biweekly_scholarship(self): if self.payperiods == 0: return decimal.Decimal(0) return self.scholarship_pay / decimal.Decimal(self.payperiods) @property def total(self): return self.total_pay + self.scholarship_pay @property def number_of_emails(self): return len(self.email_receipt.all()) def grad_students(self): """ Fetch the GradStudent record associated with this student in this semester. """ students = GradStudent.get_canonical(self.person, self.category.hiring_semester.semester) return students @property def should_be_added_to_the_course(self): return (self.status == "SGN" or self.accepted_by_student == True) and not (self.status == "CAN") @classmethod def update_ta_members(cls, person, semester_id): """ Update all TA memberships for this person+semester. Idempotent. """ from ta.models import TACourse as OldTACourse def add_membership_for(tacrs, reason, memberships): if not tacrs.contract.should_be_added_to_the_course or tacrs.total_bu <= 0: return # Find existing membership for this person+offering if it exists # (Behaviour here implies you can't be both TA and other role in one offering: I'm okay with that.) for m in memberships: if m.person == person and m.offering == tacrs.course: break else: # there was no membership: create m = Member(person=person, offering=tacrs.course) memberships.append(m) m.role = 'TA' m.credits = 0 m.career = 'NONS' m.added_reason = reason m.config['bu'] = str(tacrs.total_bu) with transaction.atomic(): memberships = Member.objects.filter(person=person, offering__semester_id=semester_id) memberships = list(memberships) # drop any TA memberships that should be re-added below for m in memberships: if m.role == 'TA' and m.added_reason in ['CTA', 'TAC']: m.role = 'DROP' # tacontracts memberships tacrses = TACourse.objects.filter(contract__person_id=person, course__semester_id=semester_id) for tacrs in tacrses: add_membership_for(tacrs, 'TAC', memberships) # ta memberships tacrses = OldTACourse.objects.filter(contract__application__person_id=person, course__semester_id=semester_id) for tacrs in tacrses: add_membership_for(tacrs, 'CTA', memberships) # save whatever just happened for m in memberships: m.save_if_dirty() def sync_course_member(self): """ Once a contract is Signed, we should create a Member object for them. If a contract is Cancelled, we should DROP the Member object. This operation should be idempotent - run it as many times as you want, the result should always be the same. """ TAContract.update_ta_members(self.person, self.category.hiring_semester.semester_id) def course_list_string(self): # Build a string of all course offerings tied to this contract for CSV downloads and grad student views. course_list_string = ', '.join(ta_course.course.name() for ta_course in self.course.all()) return course_list_string def has_attachments(self): return self.attachments.visible().count() > 0
class Memo(models.Model): """ A memo created by the system, and attached to a CareerEvent. """ career_event = models.ForeignKey(CareerEvent, null=False, blank=False) unit = models.ForeignKey( Unit, null=False, blank=False, help_text= "The unit producing the memo: will determine the letterhead used for the memo." ) sent_date = models.DateField(default=datetime.date.today, help_text="The sending date of the letter") to_lines = models.TextField(verbose_name='Attention', help_text='Recipient of the memo', null=True, blank=True) cc_lines = models.TextField(verbose_name='CC lines', help_text='Additional recipients of the memo', null=True, blank=True) from_person = models.ForeignKey(Person, null=True, related_name='+') from_lines = models.TextField( verbose_name='From', help_text= 'Name (and title) of the sender, e.g. "John Smith, Applied Sciences, Dean"' ) subject = models.TextField( help_text= 'The subject of the memo (lines will be formatted separately in the memo header)' ) template = models.ForeignKey(MemoTemplate, null=True) memo_text = models.TextField(help_text="I.e. 'Congratulations on ... '") created_at = models.DateTimeField(auto_now_add=True) created_by = models.ForeignKey(Person, help_text='Letter generation requested by.', related_name='+') hidden = models.BooleanField(default=False) config = JSONField(default={}) # addition configuration for the memo # 'use_sig': use the from_person's signature if it exists? # (Users set False when a real legal signature is required.) # 'pdf_generated': set to True if a PDF has ever been created for this memo (used to decide if it's editable) use_sig = config_property('use_sig', default=True) def autoslug(self): if self.template: return make_slug(self.career_event.slug + "-" + self.template.label) else: return make_slug(self.career_event.slug + "-memo") slug = AutoSlugField(populate_from='autoslug', null=False, editable=False, unique_with=('career_event', )) def __unicode__(self): return u"%s memo for %s" % (self.subject, self.career_event) def hide(self): self.hidden = True self.save() def save(self, *args, **kwargs): # normalize text so it's easy to work with if not self.to_lines: self.to_lines = '' self.to_lines = normalize_newlines(self.to_lines.rstrip()) self.from_lines = normalize_newlines(self.from_lines.rstrip()) self.subject = normalize_newlines(self.subject.rstrip()) self.memo_text = normalize_newlines(self.memo_text.rstrip()) self.memo_text = many_newlines.sub('\n\n', self.memo_text) super(Memo, self).save(*args, **kwargs) def uneditable_reason(self): """ Return a string indicating why this memo cannot be edited, or None. """ age = timezone.now() - self.created_at if age > datetime.timedelta(minutes=15): return 'memo is more than 15 minutes old' #elif self.config.get('pdf_generated', False): # return 'PDF has been generated, so we assume it was sent' return None def write_pdf(self, response): from dashboard.letters import OfficialLetter, MemoContents # record the fact that it was generated (for editability checking) self.config['pdf_generated'] = True self.save() doc = OfficialLetter(response, unit=self.unit) l = MemoContents( to_addr_lines=self.to_lines.split("\n"), from_name_lines=self.from_lines.split("\n"), date=self.sent_date, subject=self.subject.split("\n"), cc_lines=self.cc_lines.split("\n"), ) content_lines = self.memo_text.split("\n\n") l.add_paragraphs(content_lines) doc.add_letter(l) doc.write()
class Reminder(models.Model): reminder_type = models.CharField(max_length=4, choices=REMINDER_TYPE_CHOICES, null=False, blank=False, verbose_name='Who gets reminded?') # for reminder_type == 'PERS': the user who created the reminder. person = models.ForeignKey(Person, null=False, on_delete=models.CASCADE) # for reminder_type == 'ROLE': everyone with a specific role in a specific unit. role = models.CharField(max_length=4, null=True, blank=True, choices=ROLE_CHOICES) unit = models.ForeignKey(Unit, null=True, blank=True, on_delete=models.CASCADE) # for reminder_type == 'INST': an instructor when teaching a specific course. course = models.ForeignKey(Course, null=True, blank=True, on_delete=models.CASCADE) # also uses .person date_type = models.CharField(max_length=4, choices=REMINDER_DATE_CHOICES, null=False, blank=False) # for date_type == 'YEAR' month = models.CharField(max_length=2, null=True, blank=True, choices=MONTH_CHOICES) day = models.PositiveSmallIntegerField(null=True, blank=True) # for date_type == 'SEM' week = models.PositiveSmallIntegerField(null=True, blank=True) weekday = models.CharField(max_length=1, null=True, blank=True, choices=WEEKDAY_CHOICES) # used for all reminders title = models.CharField( max_length=100, help_text='Title for the reminder/subject for the reminder email') content = models.TextField(help_text='Text for the reminder', blank=False, null=False) status = models.CharField(max_length=1, choices=STATUS_CHOICES, default='A', blank=False, null=False) def autoslug(self): return make_slug(self.reminder_type + '-' + self.date_type + '-' + self.title) slug = AutoSlugField(populate_from='autoslug', max_length=50, null=False, editable=False, unique=True) config = JSONField(null=False, blank=False, default=dict) # addition configuration stuff # 'markup': markup language used in reminder content: see courselib/markup.py # 'math': page uses MathJax? (boolean) markup = config_property('markup', 'creole') math = config_property('math', False) objects = ReminderManager() all_objects = models.Manager() def _assert_null(self, fields): for f in fields: assert getattr(self, f) is None def _assert_non_null(self, fields): for f in fields: assert getattr(self, f) is not None def save(self, *args, **kwargs): # assert reminder_type-related fields are null/nonnull as expected if self.reminder_type == 'PERS': self._assert_null(['role', 'unit', 'course']) elif self.reminder_type == 'ROLE': self._assert_null(['course']) self._assert_non_null(['role', 'unit']) elif self.reminder_type == 'INST': self._assert_null(['role', 'unit']) self._assert_non_null(['course']) else: raise ValueError() # assert date_type-related fields are null/nonnull as expected if self.date_type == 'SEM': self._assert_null(['month', 'day']) self._assert_non_null(['week', 'weekday']) elif self.date_type == 'YEAR': self._assert_null(['week', 'weekday']) self._assert_non_null(['month', 'day']) else: raise ValueError() res = super().save(*args, **kwargs) # destroy any unsent ReminderMessages and recreate to reflect changes. with transaction.atomic(): ReminderMessage.objects.filter(reminder=self, sent=False).delete() self.create_reminder_messages(allow_stale=False) return res def __str__(self): return 'Reminder(slug=%r, person__userid=%r, title=%r)' % ( self.slug, self.person.userid, self.title) def get_absolute_url(self): return reverse('reminders:view', kwargs={'reminder_slug': self.slug}) def can_be_accessed_by(self, person): "Can this person read & edit this reminder?" if self.reminder_type in ['PERS', 'INST']: return self.person == person elif self.reminder_type == 'ROLE': roles = Role.objects.filter( role=self.role, unit=self.unit).select_related('person') people = {r.person for r in roles} return person in people else: raise ValueError() @staticmethod def relevant_courses(person): cutoff = datetime.date.today() - datetime.timedelta(days=365) instructors = Member.objects.filter( role='INST', person=person, offering__semester__end__gt=cutoff).select_related( 'offering__course') return {m.offering.course for m in instructors} def date_sort(self): "Sortable string for date_type etc." if self.date_type == 'SEM': return 'sem-%02i-%i' % (self.week, int(self.weekday)) elif self.date_type == 'YEAR': return 'year-%02i-%02i' % (int(self.month), self.day) else: raise ValueError() def when_description(self): "Human-readable description of when this reminder fires." if self.date_type == 'SEM': return 'each semester, week %i on %s' % ( self.week, self.get_weekday_display()) elif self.date_type == 'YEAR': return 'each year, %s %i' % (self.get_month_display(), self.day) else: raise ValueError() def who_description(self): "Human-readable description of who gets this reminder." if self.reminder_type == 'PERS': return 'you personally' elif self.reminder_type == 'ROLE': return '%s(s) in %s' % (ROLES[self.role], self.unit.label) elif self.reminder_type == 'INST': return 'you when teaching %s' % (self.course) else: raise ValueError() def html_content(self): return markup_to_html(self.content, self.markup, restricted=True) # ReminderMessage-related functionality @staticmethod def reminder_message_range(allow_stale=True): "Date range where we want to maybe create ReminderMessages." today = datetime.date.today() if allow_stale: start = today - datetime.timedelta(days=MAX_LATE) else: start = today return start, today + datetime.timedelta(days=MESSAGE_EARLY_CREATION) def create_reminder_on(self, date, start_date, end_date): if start_date > date or date > end_date: # not timely, so ignore return if self.reminder_type == 'ROLE': roles = Role.objects_fresh.filter( unit=self.unit, role=self.role).select_related('person') recipients = [r.person for r in roles] elif self.reminder_type in ['PERS', 'INST']: recipients = [self.person] else: raise ValueError() for recip in recipients: ident = '%s_%s_%s' % (self.slug, recip.userid_or_emplid(), date.isoformat()) # ident length: slug (50) + userid/emplid (9) + ISO date (10) + _ (2) <= 71 rm = ReminderMessage(reminder=self, sent=False, date=date, person=recip, ident=ident) with transaction.atomic(): try: rm.save() except IntegrityError: # already been created because we got IntegrityError on rm.ident pass def create_reminder_messages(self, start_date=None, end_date=None, allow_stale=True): """ Create any ReminderMessages that don't already exist, between startdate and enddate. Idempotent. """ if self.status == 'D': return if not start_date or not end_date: start_date, end_date = self.reminder_message_range( allow_stale=allow_stale) if self.date_type == 'YEAR': next1 = datetime.date(year=start_date.year, month=int(self.month), day=self.day) next2 = datetime.date(year=start_date.year + 1, month=int(self.month), day=self.day) self.create_reminder_on(next1, start_date, end_date) self.create_reminder_on(next2, start_date, end_date) elif self.date_type == 'SEM': if self.reminder_type == 'INST': # limit to semesters actually teaching instructors = Member.objects.filter(role='INST', person=self.person, offering__course=self.course) \ .exclude(offering__component='CAN').select_related('offering__course') semesters = {m.offering.semester for m in instructors} else: semesters = None this_sem = Semester.current() for sem in [ this_sem.previous_semester(), this_sem, this_sem.next_semester() ]: if semesters is None or sem in semesters: next = sem.duedate(self.week, int(self.weekday), time=None) self.create_reminder_on(next, start_date, end_date) else: raise ValueError() @classmethod def create_all_reminder_messages(cls): """ Create ReminderMessages for all Reminders. Idempotent. """ startdate, enddate = cls.reminder_message_range() for r in cls.objects.all(): # can we do better than .all()? r.create_reminder_messages(startdate, enddate)