コード例 #1
0
ファイル: models.py プロジェクト: tjkind/coursys
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)
コード例 #2
0
ファイル: models.py プロジェクト: vanthaiunghoa/coursys
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)
コード例 #3
0
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()
コード例 #4
0
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
コード例 #5
0
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()]
        }
コード例 #6
0
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
コード例 #7
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)
    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()
コード例 #8
0
ファイル: models.py プロジェクト: vanthaiunghoa/coursys
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 ''
コード例 #9
0
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
コード例 #10
0
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,
        }
コード例 #11
0
ファイル: models.py プロジェクト: vanthaiunghoa/coursys
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()
コード例 #12
0
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
コード例 #13
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)
コード例 #14
0
ファイル: models.py プロジェクト: xhacker/coursys
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()
コード例 #15
0
ファイル: models.py プロジェクト: peterirani/coursys
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
コード例 #16
0
ファイル: models.py プロジェクト: csvenja/coursys
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()
コード例 #17
0
ファイル: models.py プロジェクト: vanthaiunghoa/coursys
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)