Skip to content
models.py 54.4 KiB
Newer Older
            tags = getattr(flow_desc.rules, "tags", None)
            grade_identifier = flow_desc.rules.grade_identifier
            if self.kind == flow_rule_kind.start:
                validate_session_start_rule(ctx, six.text_type(self), rule, tags)
            elif self.kind == flow_rule_kind.access:
                validate_session_access_rule(ctx, six.text_type(self), rule, tags)
            elif self.kind == flow_rule_kind.grading:
                validate_session_grading_rule(
                        ctx, six.text_type(self), rule, tags,
                        grade_identifier)
                # the rule refers to FlowRuleException rule
ifaint's avatar
ifaint committed
                raise ValidationError(_("invalid rule kind: ")+self.kind)

        except ContentValidationError as e:
            # the rule refers to FlowRuleException rule
ifaint's avatar
ifaint committed
            raise ValidationError(_("invalid existing_session_rules: ")+str(e))
ifaint's avatar
ifaint committed
        verbose_name = _("Flow rule exception")
ifaint's avatar
ifaint committed
        verbose_name_plural = _("Flow rule exceptions")
# }}}


# {{{ grading

class GradingOpportunity(models.Model):
ifaint's avatar
ifaint committed
    course = models.ForeignKey(Course,
            verbose_name=_('Course'), on_delete=models.CASCADE)

    identifier = models.CharField(max_length=200, blank=False, null=False,
ifaint's avatar
ifaint committed
            # Translators: format of identifier for GradingOpportunity
ifaint's avatar
ifaint committed
            help_text=_("A symbolic name for this grade. "
ifaint's avatar
ifaint committed
            "lower_case_with_underscores, no spaces."),
ifaint's avatar
ifaint committed
            verbose_name=_('Grading opportunity ID'))
    name = models.CharField(max_length=200, blank=False, null=False,
ifaint's avatar
ifaint committed
            help_text=_("A human-readable identifier for the grade."),
ifaint's avatar
ifaint committed
            verbose_name=_('Grading opportunity name'))
    flow_id = models.CharField(max_length=200, blank=True, null=True,
ifaint's avatar
ifaint committed
            help_text=_("Flow identifier that this grading opportunity "
ifaint's avatar
ifaint committed
            "is linked to, if any"),
ifaint's avatar
ifaint committed
            verbose_name=_('Flow ID'))
    aggregation_strategy = models.CharField(max_length=20,
ifaint's avatar
ifaint committed
            choices=GRADE_AGGREGATION_STRATEGY_CHOICES,
            # Translators: strategy on how the grading of mutiple sessioins
ifaint's avatar
ifaint committed
            verbose_name=_('Aggregation strategy'))
ifaint's avatar
ifaint committed
    due_time = models.DateTimeField(default=None, blank=True, null=True,
ifaint's avatar
ifaint committed
            verbose_name=_('Due time'))
ifaint's avatar
ifaint committed
    creation_time = models.DateTimeField(default=now,
ifaint's avatar
ifaint committed
            verbose_name=_('Creation time'))
ifaint's avatar
ifaint committed
    shown_in_grade_book = models.BooleanField(default=True,
ifaint's avatar
ifaint committed
            verbose_name=_('Shown in grade book'))
    shown_in_participant_grade_book = models.BooleanField(default=True,
            verbose_name=_("Shown in student grade book"))
    page_scores_in_participant_gradebook = models.BooleanField(default=False,
            verbose_name=_("Scores for individual pages are shown "
                "in the participants' grade book"))
    hide_superseded_grade_history_before = models.DateTimeField(
            verbose_name=_('Hide superseded grade history before'),
            blank=True, null=True,
            help_text=_(
                'Grade changes dated before this date that are '
                'superseded by later grade changes will not be shown to '
                'participants. '
                'This can help avoid discussions about pre-release grading '
                'adjustments.'
                'May be blank. In that case, the entire grade history is '
                'shown.'))
ifaint's avatar
ifaint committed
        verbose_name = _("Grading opportunity")
        verbose_name_plural = _("Grading opportunities")
        ordering = ("course", "due_time", "identifier")
        unique_together = (("course", "identifier"),)
Dong Zhuang's avatar
Dong Zhuang committed
                # Translators: For GradingOpportunity
                _("%(opportunity_name)s (%(opportunity_id)s) in %(course)s")
                % {
                    "opportunity_name": self.name,
                    "opportunity_id": self.identifier,
                    "course": self.course})
    if six.PY3:
        __str__ = __unicode__

    def get_aggregation_strategy_descr(self):
        return dict(GRADE_AGGREGATION_STRATEGY_CHOICES).get(
                self.aggregation_strategy)


class GradeChange(models.Model):
    """Per 'grading opportunity', each participant may accumulate multiple grades
    that are aggregated according to :attr:`GradingOpportunity.aggregation_strategy`.

    In addition, for each opportunity, grade changes are grouped by their 'attempt'
    identifier, where later grades with the same :attr:`attempt_id` supersede earlier
    ones.
    """
ifaint's avatar
ifaint committed
    opportunity = models.ForeignKey(GradingOpportunity,
            verbose_name=_('Grading opportunity'), on_delete=models.CASCADE)
ifaint's avatar
ifaint committed
    participation = models.ForeignKey(Participation,
            verbose_name=_('Participation'), on_delete=models.CASCADE)

    state = models.CharField(max_length=50,
ifaint's avatar
ifaint committed
            choices=GRADE_STATE_CHANGE_CHOICES,
            # Translators: something like 'status'.
ifaint's avatar
ifaint committed
            verbose_name=_('State'))
    attempt_id = models.CharField(max_length=50, null=True, blank=True,
            default="main",
ifaint's avatar
ifaint committed
            # Translators: help text of "attempt_id" in GradeChange class
ifaint's avatar
ifaint committed
            help_text=_("Grade changes are grouped by their 'attempt ID' "
            "where later grades with the same attempt ID supersede earlier "
ifaint's avatar
ifaint committed
            "ones."),
ifaint's avatar
ifaint committed
            verbose_name=_('Attempt ID'))
    points = models.DecimalField(max_digits=10, decimal_places=2,
ifaint's avatar
ifaint committed
            blank=True, null=True,
ifaint's avatar
ifaint committed
            verbose_name=_('Points'))
ifaint's avatar
ifaint committed
    max_points = models.DecimalField(max_digits=10, decimal_places=2,
ifaint's avatar
ifaint committed
            verbose_name=_('Max points'))
ifaint's avatar
ifaint committed
    comment = models.TextField(blank=True, null=True,
ifaint's avatar
ifaint committed
            verbose_name=_('Comment'))
ifaint's avatar
ifaint committed
    due_time = models.DateTimeField(default=None, blank=True, null=True,
ifaint's avatar
ifaint committed
            verbose_name=_('Due time'))
    creator = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
            verbose_name=_('Creator'), on_delete=models.CASCADE)
ifaint's avatar
ifaint committed
    grade_time = models.DateTimeField(default=now, db_index=True,
ifaint's avatar
ifaint committed
            verbose_name=_('Grade time'))
    flow_session = models.ForeignKey(FlowSession, null=True, blank=True,
ifaint's avatar
ifaint committed
            related_name="grade_changes",
            verbose_name=_('Flow session'), on_delete=models.CASCADE)
ifaint's avatar
ifaint committed
        verbose_name = _("Grade change")
        verbose_name_plural = _("Grade changes")
        ordering = ("opportunity", "participation", "grade_time")

    def __unicode__(self):
        # Translators: information for GradeChange
        return _("%(participation)s %(state)s on %(opportunityname)s") % {
            'participation': self.participation,
            'state': self.state,
            'opportunityname': self.opportunity.name}
    if six.PY3:
        __str__ = __unicode__

        if self.opportunity.course != self.participation.course:
ifaint's avatar
ifaint committed
            raise ValidationError(_("Participation and opportunity must live "
                    "in the same course"))
    def percentage(self):
        if self.max_points is not None and self.points is not None:
            return 100*self.points/self.max_points
        else:
            return None
    def get_state_desc(self):
        return dict(GRADE_STATE_CHANGE_CHOICES).get(
                self.state)

# }}}


# {{{ grade state machine

class GradeStateMachine(object):
    def __init__(self):
        self.opportunity = None

        self.state = None
        self._clear_grades()
        self.due_time = None
        self.last_graded_time = None
        self.last_report_time = None

        # applies to *all* grade changes
        self._last_grade_change_time = None

    def _clear_grades(self):
        self.state = None
        self.last_grade_time = None
        self.valid_percentages = []
        self.attempt_id_to_gchange = {}
    def _consume_grade_change(self, gchange, set_is_superseded):
        if self.opportunity is None:
            self.opportunity = gchange.opportunity
            self.due_time = self.opportunity.due_time
        else:
            assert self.opportunity.pk == gchange.opportunity.pk

        # check that times are increasing
        if self._last_grade_change_time is not None:
            assert gchange.grade_time > self._last_grade_change_time
            self._last_grade_change_time = gchange.grade_time

        if gchange.state == grade_state_change_types.graded:
            if self.state == grade_state_change_types.unavailable:
                raise ValueError(
                        _("cannot accept grade once opportunity has been "
                            "marked 'unavailable'"))
            if self.state == grade_state_change_types.exempt:
                raise ValueError(
                        _("cannot accept grade once opportunity has been "
ifaint's avatar
ifaint committed
                        "marked 'exempt'"))
            #if self.due_time is not None and gchange.grade_time > self.due_time:
                #raise ValueError("cannot accept grade after due date")

            self.state = gchange.state
            if gchange.attempt_id is not None:
                if (set_is_superseded and
                        gchange.attempt_id in self.attempt_id_to_gchange):
                    self.attempt_id_to_gchange[gchange.attempt_id] \
                            .is_superseded = True
                self.attempt_id_to_gchange[gchange.attempt_id] \
                        = gchange
            else:
                self.valid_percentages.append(gchange.percentage())
            self.last_graded_time = gchange.grade_time

        elif gchange.state == grade_state_change_types.unavailable:
            self._clear_grades()
            self.state = gchange.state

        elif gchange.state == grade_state_change_types.do_over:
            self._clear_grades()

        elif gchange.state == grade_state_change_types.exempt:
            self._clear_grades()
            self.state = gchange.state

        elif gchange.state == grade_state_change_types.report_sent:
            self.last_report_time = gchange.grade_time

        elif gchange.state == grade_state_change_types.extension:
            self.due_time = gchange.due_time

        elif gchange.state in [
                grade_state_change_types.grading_started,
                grade_state_change_types.retrieved,
                ]:
            pass
        else:
            raise RuntimeError(
                    _("invalid grade change state '%s'") % gchange.state)
    def consume(self, iterable, set_is_superseded=False):
        for gchange in iterable:
            gchange.is_superseded = False
            self._consume_grade_change(gchange, set_is_superseded)

        valid_grade_changes = sorted(
                (gchange
                for gchange in self.attempt_id_to_gchange.values()
                if gchange.percentage() is not None),
                key=lambda gchange: gchange.grade_time)

        self.valid_percentages.extend(
                gchange.percentage()
        del self.attempt_id_to_gchange

        return self

    def percentage(self):
        """
        :return: a percentage of achieved points, or *None*
        """
        if self.opportunity is None or not self.valid_percentages:
            return None

        strategy = self.opportunity.aggregation_strategy

        if strategy == grade_aggregation_strategy.max_grade:
            return max(self.valid_percentages)
        elif strategy == grade_aggregation_strategy.min_grade:
            return min(self.valid_percentages)
        elif strategy == grade_aggregation_strategy.avg_grade:
            return sum(self.valid_percentages)/len(self.valid_percentages)
        elif strategy == grade_aggregation_strategy.use_earliest:
            return self.valid_percentages[0]
        elif strategy == grade_aggregation_strategy.use_latest:
            return self.valid_percentages[-1]
        else:
            raise ValueError(
                    _("invalid grade aggregation strategy '%s'") % strategy)
    def stringify_state(self):
        if self.state is None:
            return u"- ∅ -"
        elif self.state == grade_state_change_types.exempt:
ifaint's avatar
ifaint committed
            return "_((exempt))"
        elif self.state == grade_state_change_types.graded:
            if self.valid_percentages:
                result = "%.1f%%" % self.percentage()
                if len(self.valid_percentages) > 1:
                    result += " (/%d)" % len(self.valid_percentages)
                return result
            else:
                return u"- ∅ -"
ifaint's avatar
ifaint committed
            return "_((other state))"
    def stringify_machine_readable_state(self):
        if self.state is None:
            return u"NONE"
        elif self.state == grade_state_change_types.exempt:
            return "EXEMPT"
        elif self.state == grade_state_change_types.graded:
            if self.valid_percentages:
                return "%.3f" % self.percentage()
            else:
                return u"NONE"
        else:
            return u"OTHER_STATE"

    def stringify_percentage(self):
        if self.state == grade_state_change_types.graded:
            if self.valid_percentages:
                return "%.1f" % self.percentage()
            else:
                return u""
        else:
            return ""
# }}}


# {{{ flow <-> grading integration

def get_flow_grading_opportunity(course, flow_id, flow_desc, grading_rule):
    from course.utils import FlowSessionGradingRule
    assert isinstance(grading_rule, FlowSessionGradingRule)

    default_name = (
            # Translators: display the name of a flow
            _("Flow: %(flow_desc_title)s")
            % {"flow_desc_title": flow_desc.title})
    gopp, created = GradingOpportunity.objects.get_or_create(
            course=course,
            identifier=grading_rule.grade_identifier,
                flow_id=flow_id,
                aggregation_strategy=grading_rule.grade_aggregation_strategy,
    # update gopp.name when flow_desc.title changed
    if not created:
        if gopp.name != default_name:
            gopp.name = default_name
            gopp.save()


# {{{ XMPP log

class InstantMessage(models.Model):
ifaint's avatar
ifaint committed
    participation = models.ForeignKey(Participation,
            verbose_name=_('Participation'), on_delete=models.CASCADE)
ifaint's avatar
ifaint committed
    text = models.CharField(max_length=200,
ifaint's avatar
ifaint committed
            verbose_name=_('Text'))
ifaint's avatar
ifaint committed
    time = models.DateTimeField(default=now,
ifaint's avatar
ifaint committed
            verbose_name=_('Time'))
ifaint's avatar
ifaint committed
        verbose_name = _("Instant message")
        verbose_name_plural = _("Instant messages")
        ordering = ("participation__course", "time")

    def __unicode__(self):
        return "%s: %s" % (self.participation, self.text)

    if six.PY3:
        __str__ = __unicode__


class Exam(models.Model):
    course = models.ForeignKey(Course,
            verbose_name=_('Course'), on_delete=models.CASCADE)
    description = models.CharField(max_length=200,
            verbose_name=_('Description'))
    flow_id = models.CharField(max_length=200,
            verbose_name=_('Flow ID'))
    active = models.BooleanField(
            default=True,
            verbose_name=_('Currently active'))

    no_exams_before = models.DateTimeField(
            verbose_name=_('No exams before'))
    no_exams_after = models.DateTimeField(
            null=True, blank=True,
            verbose_name=_('No exams after'))

    class Meta:
        verbose_name = _("Exam")
        verbose_name_plural = _("Exams")
        ordering = ("course", "no_exams_before",)

    def __unicode__(self):
        return _("Exam  %(description)s in %(course)s") % {
                'description': self.description,
                'course': self.course,
                }

    if six.PY3:
        __str__ = __unicode__


class ExamTicket(models.Model):
    exam = models.ForeignKey(Exam,
            verbose_name=_('Exam'), on_delete=models.CASCADE)

    participation = models.ForeignKey(Participation, db_index=True,
            verbose_name=_('Participation'), on_delete=models.CASCADE)

    creator = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
            verbose_name=_('Creator'), on_delete=models.CASCADE)
    creation_time = models.DateTimeField(default=now,
            verbose_name=_('Creation time'))
    usage_time = models.DateTimeField(
            verbose_name=_('Date and time of first usage of ticket'),
            null=True, blank=True)

    state = models.CharField(max_length=50,
            choices=EXAM_TICKET_STATE_CHOICES,
            verbose_name=_('Exam ticket state'))

    code = models.CharField(max_length=50, db_index=True, unique=True)

    class Meta:
        verbose_name = _("Exam ticket")
        verbose_name_plural = _("Exam tickets")
        ordering = ("exam__course", "-creation_time")
        permissions = (
                ("can_issue_exam_tickets", _("Can issue exam tickets to student")),
                )
    def __unicode__(self):
        return _("Exam  ticket for %(participation)s in %(exam)s") % {
                'participation': self.participation,
                'exam': self.exam,
                }

    if six.PY3:
        __str__ = __unicode__

    def clean(self):
        super(ExamTicket, self).clean()

        try:
            if self.exam.course != self.participation.course:
                raise ValidationError(_("Participation and exam must live "
                        "in the same course"))
        except ObjectDoesNotExist:
            pass

# vim: foldmethod=marker