예제 #1
0
class FriendshipStatus(models.StatusModel):
    """
    Defines the friendship status between two users.
    """
    STATUS_PENDING = 'pending'
    STATUS_FRIEND = 'friend'
    STATUS_UNFRIEND = 'unfriend'
    STATUS_COLLEAGUE = 'colleague'
    STATUS = models.Choices(
        (STATUS_PENDING, _('pending')), (STATUS_FRIEND, _('friend')),
        (STATUS_UNFRIEND, _('unfriend')), (STATUS_COLLEAGUE, _('colleague')))
    owner = models.ForeignKey(models.User, related_name='related_users')
    other = models.ForeignKey(models.User,
                              related_name='related_users_as_other')

    class Meta:
        unique_together = ('owner', 'other'),

    def save(self, *args, **kwds):
        super().save(*args, **kwds)

        try:
            FriendshipStatus.objects.get(owner=self.other, other=self.owner)
        except FriendshipStatus.DoesNotExist:
            reciprocal = FriendshipStatus(owner=self.other, other=self.owner)
            if self.status == self.STATUS_COLLEAGUE:
                reciprocal.status = self.STATUS_COLLEAGUE
            else:
                reciprocal.status = self.STATUS_PENDING
            reciprocal.save()
예제 #2
0
class Response(models.InheritableModel, models.TimeStampedStatusModel):
    """
    Represents a student's response to some activity. The student may submit
    many responses for the same object. It is also possible to submit
    different responses with different students.
    """

    STATUS_PENDING = 'pending'
    STATUS_WAITING = 'waiting'
    STATUS_INVALID = 'invalid'
    STATUS_DONE = 'done'
    STATUS = models.Choices(
        (STATUS_PENDING, _('pending')),
        (STATUS_WAITING, _('waiting')),
        (STATUS_DONE, _('done')),
    )
    activity = models.ForeignKey(Activity, blank=True, null=True)
    user = models.ForeignKey(models.User)
    grade = models.DecimalField(
        'Percentage of maximum grade',
        max_digits=6,
        decimal_places=3,
        blank=True,
        null=True,
    )
    data = models.PickledObjectField(blank=True, null=True)

    #
    # Visualization
    #
    ok_message = '*Contratulations!* Your response is correct!'
    wrong_message = 'I\'m sorry, your response is wrong.'
    partial_message = 'Your answer is partially correct: you made %(grade)d%% of the total grade.'

    def as_html(self):
        data = {'grade': self.grade * 100}
        if self.grade == 1:
            return markdown(self.ok_message)
        elif self.grade == 0:
            return markdown(self.wrong_message)
        else:
            return markdown(self.partial_message % data)

    def __str__(self):
        tname = type(self).__name__
        return '%s(%s, grade=%s)' % (tname, self.activity, self.grade)
예제 #3
0
class FriendshipStatus(models.StatusModel):
    STATUS = models.Choices(('pending', _('pending')), ('friend', _('friend')),
                            ('acquaintance', _('acquaintance')),
                            ('unfriend', _('unfriend')))

    owner = models.ForeignKey(models.User, related_name='associated')
    other = models.ForeignKey(models.User, related_name='associated_as_other')

    class Meta:
        unique_together = ('owner', 'other'),

    def save(self, *args, **kwds):
        super().save(*args, **kwds)

        try:
            FriendshipStatus.objects.get(owner=self.other, other=self.owner)
        except FriendshipStatus.DoesNotExist:
            FriendshipStatus(owner=self.other,
                             other=self.owner,
                             status='pending').save()
예제 #4
0
파일: models.py 프로젝트: cslms/cs-server
class Task(models.Model):
    """
    A task that can be on the backlog or on a sprint.
    """

    STATUS_BACKLOG = 0
    STATUS_TODO = 1
    STATUS_DOING = 2
    STATUS_DONE = 3
    STATUS = models.Choices(
        (STATUS_BACKLOG, 'backlog'),
        (STATUS_TODO, 'todo'),
        (STATUS_DOING, 'doing'),
        (STATUS_DONE, 'done'),
    )
    sprint = models.ForeignKey(Sprint, related_name='tasks')
    project = models.ForeignKey(ScrumProject, related_name='tasks')
    status = models.StatusField()
    created_by = models.ForeignKey(models.User, related_name='+')
    assigned_to = models.ManyToManyField(models.User, related_name='+')
    description = models.RichTextField()
    duration_hours = models.IntegerField()
    objects = TaskQuerySet.as_manager()
예제 #5
0
class Activity(models.CopyMixin, models.InheritableModel,
               models.DescribableModel, models.TimeFramedModel):
    """
    Represents a gradable activity inside a course. Activities may not have an
    explicit grade, but yet may provide points to the students via the
    gamefication features of Codeschool.

    Activities can be scheduled to be done in the class or as a homework
    assignment.

    Each concrete activity is represented by a different subclass.
    """
    class Meta:
        verbose_name = _('activity')
        verbose_name_plural = _('activities')

    STATUS_OPEN = 'open'
    STATUS_CLOSED = 'closed'
    STATUS_VISIBLE = 'visible'
    STATUS_DRAFT = 'draft'
    STATUS_EXPIRED = 'expired',
    STATUS = models.Choices(
        (STATUS_DRAFT, _('draft')),
        (STATUS_OPEN, _('open')),
        (STATUS_CLOSED, _('closed')),
        (STATUS_VISIBLE, _('visible')),
        (STATUS_EXPIRED, _('expired')),
    )
    status = models.StatusField(
        _('status'),
        help_text=_(
            'Only open activities will be visible and active to all students.'
        ),
    )
    published_at = models.MonitorField(_('date of publication'),
                                       monitor='status',
                                       when=['open'])
    icon_src = models.CharField(
        max_length=50,
        blank=True,
        help_text=_(
            'Optional icon name that can be used to personalize the activity. '
            'Material icons are available by using the "material:" namespace '
            'as in "material:menu".'),
    )
    owner_content_type = models.ForeignKey(
        ContentType,
        verbose_name=_('owner model type'),
        limit_choices_to=ACTIVITY_OWNER_CONTENT_CHOICES,
        related_name='activities_as_owner',
        null=True,
        blank=True,
    )
    owner_id = models.PositiveIntegerField(
        _("owner model's id"),
        null=True,
        blank=True,
    )
    target_content_type = models.ForeignKey(
        ContentType,
        verbose_name=_('target model type'),
        related_name='activities_as_target',
        null=True,
        blank=True,
    )
    target_id = models.PositiveIntegerField(
        _("target model's id"),
        null=True,
        blank=True,
    )
    course = models.ForeignKey(
        'cs_courses.Course',
        related_name='activities',
        blank=True,
        null=True,
    )
    parent = models.ForeignKey('self',
                               blank=True,
                               null=True,
                               on_delete=models.SET_NULL,
                               related_name='children')
    grading_method = models.ForeignKey(
        GradingMethod,
        default=grading_method_best,
        blank=True,
    )

    #: The owner object is either a course object or an user object. This
    #: object has control to the given activity and define which users have
    #: permissions to access and edit it.
    owner_object = GenericForeignKey('owner_content_type', 'owner_id')

    #: The owner object is either a course object or an user object. This
    #: object has control to the given activity and define which users have
    #: permissions to access and edit it.
    target_object = GenericForeignKey('target_content_type', 'target_id')

    objects = ActivityQueryset.as_manager()

    @property
    def course_(self):
        """Points to the course object or None if owner is not a course."""

        obj = self.owner_object
        return obj if isinstance(obj, Course) else None

    @property
    def owner(self):
        """Points to the user that owns the activity."""

        obj = self.owner_object
        if isinstance(obj, models.User):
            return obj
        else:
            return self.course.owner

    #: Define the default material icon used in conjunction with instances of
    #: the activity class.
    default_material_icon = 'help'

    #: The response class associated with the given activity.
    response_class = None

    @property
    def material_icon(self):
        """The material icon used in conjunction with the activity."""

        if self.icon_src.startswith('material:'):
            return self.icon_src[9:]
        return self.default_material_icon

    @property
    def icon_html(self):
        """A string of HTML source that points to the icon element fo the
        activity."""

        return '<i class="material-icon">%s</i>' % self.material_icon

    # Permission control
    def can_edit(self, user):
        """
        Return True if user has permissions to edit activity.
        """

        return user == self.owner or self.course.can_edit(user)

    def can_view(self, user):
        """
        Return True if user has permission to view activity.
        """

        course = self.course
        return (self.can_edit(user) or user in course.students.all()
                or user in self.staff.all())

    # Other functions
    def get_absolute_url(self):
        return reverse('activity:detail', kwargs={'pk': self.pk})

    # Response and grading control
    def has_user_response(self, user):
        """
        Return True if the user has responsed to the question.

        Use either :func:`Activity.get_user_response` or
        :func:`Activity.get_user_responses` methods to fetch the user responses.
        """

        return bool(self.responses.filter(user=user))

    def get_user_response(self, user, method='first'):
        """
        Return some response given by the user or None if the user has not
        responded.

        Allowed methods:
            unique:
                Expects that response is unique and return it (or None).
            any:
                Return a random user response.
            first:
                Return the first response given by the user.
            last:
                Return the last response given by the user.
            best:
                Return the response with the best final grade.
            worst:
                Return the response with the worst final grade.
            best-given:
                Return the response with the best given grade.
            worst-given:
                Return the response with the worst given grade.

        """

        responses = self.responses.filter(user=user)
        first = lambda x: x.select_subclasses().first()

        if method == 'unique':
            N = self.responses.count()
            if N == 0:
                return None
            elif N == 1:
                return response.select_subclasses().first()
            else:
                raise ValueError('more than one response found for user %r' %
                                 user.username)
        elif method == 'any':
            return first(responses)
        elif method == 'first':
            return first(responses.order_by('created'))
        elif method == 'last':
            return first(responses.order_by('-created'))
        elif method in ['best', 'worst', 'best-given', 'worst-given']:
            raise NotImplementedError('method = %r is not implemented yet' %
                                      method)
        else:
            raise ValueError('invalid method: %r' % method)

    def get_user_responses(self, user):
        """
        Return all responses by the given user.
        """

        return self.responses.filter(user=user).select_subclasses()

    def get_user_final_response(self, user):
        """Return the FinalResponse object associated with the given user."""

        try:
            return self.final_responses.get(user=user)
        except ObjectDoesNotExist:
            return self.final_responses.create(user=user)

    def get_user_grade(self, user):
        """
        Return the numeric grade associated with the user.
        """

        final_response = self.get_user_final_response(user)
        return final_response.grade()

    def select_responses(self):
        """
        Return a queryset with all responses related to the given question.
        """
        from cs_activities.models import Response

        if not force:
            responses = self.responses.filter(status=Response.STATUS_PENDING)
        else:
            responses = self.responses.all()
        return responses.select_subclasses()

    def grade_responses(self, force=False):
        """
        Grade all responses that had not been graded yet.

        This function may take a while to run, locking the server. Maybe it is
        a good idea to run it as a task or in a separate thread.

        Args:
            force (boolean):
                If True, forces the response to be re-graded.
        """

        # Run autograde on each responses
        for response in responses:
            response.autograde(force=force)

    def select_users(self):
        """
        Return a queryset with all users that responded to the activity.
        """

        user_ids = self.responses.values_list('user', flat=True).distinct()
        users = models.User.objects.filter(id__in=user_ids)
        return users

    def get_grades(self, users=None):
        """
        Return a dictionary mapping each user to their respective grade in the
        activity.

        If a list of users is given, include only the users in this list.
        """

        if users is None:
            users = self.select_users()

        grades = {}
        for user in users:
            grade = self.get_user_grade(user)
            grades[user] = grade
        return grades
예제 #6
0
class Response(models.CopyMixin, models.InheritableModel,
               models.TimeStampedStatusModel):
    """
    Represents a student's response to some activity.

    Response objects have 4 different states:

    pending:
        The response has been sent, but was not graded. Grading can be manual or
        automatic, depending on the activity.
    waiting:
        Waiting for manual feedback.
    invalid:
        The response has been sent, but contains malformed data.
    done:
        The response was graded and evaluated and it initialized a feedback
        object.

    A response always starts at pending status. We can request it to be graded
    by calling the :func:`Response.autograde` method. This method must raise
    an InvalidResponseError if the response is invalid or ManualGradingError if
    the response subclass does not implement automatic grading.
    """
    class Meta:
        verbose_name = _('response')
        verbose_name_plural = _('responses')

    STATUS_PENDING = 'pending'
    STATUS_WAITING = 'waiting'
    STATUS_INVALID = 'invalid'
    STATUS_DONE = 'done'
    STATUS = models.Choices(
        (STATUS_PENDING, _('pending')),
        (STATUS_WAITING, _('waiting')),
        (STATUS_INVALID, _('invalid')),
        (STATUS_DONE, _('done')),
    )
    activity = models.ForeignKey(
        Activity,
        blank=True,
        null=True,
        related_name='responses',
        on_delete=models.CASCADE,
    )
    user = models.ForeignKey(
        models.User,
        blank=True,
        null=True,
    )
    feedback_data = models.PickledObjectField(blank=True, null=True)
    given_grade = models.DecimalField(
        _('Percentage of maximum grade'),
        help_text=_(
            'This grade is given by the auto-grader and represents the grade '
            'for the response before accounting for any bonuses or penalties.'
        ),
        max_digits=6,
        decimal_places=3,
        blank=True,
        null=True,
    )
    final_grade = models.DecimalField(
        _('Final grade'),
        help_text=_(
            'Similar to given_grade, but can account for additional factors '
            'such as delay penalties or for any other reason the teacher may '
            'want to override the student\'s grade.'),
        max_digits=6,
        decimal_places=3,
        blank=True,
        null=True,
    )
    manual_override = models.BooleanField(default=False)
    parent = models.ForeignKey(
        'self',
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        related_name='children',
    )
    is_converted = models.BooleanField(default=False)

    # Status properties
    is_done = property(lambda x: x.status == x.STATUS_DONE)
    is_pending = property(lambda x: x.status == x.STATUS_PENDING)
    is_waiting = property(lambda x: x.status == x.STATUS_WAITING)
    is_invalid = property(lambda x: x.status == x.STATUS_INVALID)

    # Delegate properties
    course = property(lambda x: getattr(x.activity, 'course', None))

    # Other properties
    @property
    def grade(self):
        if self.final_grade is None:
            return self.given_grade or decimal.Decimal(0)
        else:
            return self.final_grade

    grade.setter(lambda x, v: setattr(x, 'final_grade', v))

    class InvalidResponseError(Exception):
        """Raised by compute_response() when the response is invalid."""

    # Compute grades
    def get_response_group(self, user):
        """Return the response group associated to this response."""

    def get_feedback(self, commit=True):
        """Return the feedback object associated to the given response.

        This method may trigger the autograde() method, if grading was not
        performed yet. If you want to defer database access, call it with
        commit=False to prevent saving any modifications to the response object
        to the database.
        """

        if self.status == self.STATUS_PENDING:
            self.autograde(commit)
        elif self.status == self.STATUS_INVALID:
            raise self.feedback_data
        elif self.status == self.STATUS_WAITING:
            return None
        return self.feedback_data

    def autograde(self, commit=True, force=False):
        """
        Performs automatic grading.

        Response subclasses must implement the autograde_compute() method in
        order to make automatic grading work. This method may write any
        relevant information to the `feedback_data` attribute and must return
        a numeric value from 0 to 100 with the given automatic grade.
        """

        if self.status == self.STATUS_PENDING or force:
            try:
                value = self.autograde_compute()
            except self.InvalidResponseError as ex:
                self.status = self.STATUS_INVALID
                self.feedback_data = ex
                self.given_grade = self.final_grade = decimal.Decimal(0)
                if commit:
                    self.save()
                raise

            if value is None:
                self.status = self.STATUS_WAITING
            else:
                self.given_grade = decimal.Decimal(value)
                self.final_grade = self.given_grade
                self.status = self.STATUS_DONE
            if commit and self.pk:
                self.save(update_fields=[
                    'status', 'feedback_data', 'given_grade', 'final_grade'
                ])
            elif commit:
                self.save()

        elif self.status == self.STATUS_INVALID:
            raise self.feedback_data

    def autograde_compute(self):
        """This method should be implemented in subclasses."""

        raise ImproperlyConfigured(
            'Response subclass %r must implement the autograde_compute().'
            'This method should perform the automatic grading and return the '
            'resulting grade. Any additional relevant feedback data might be '
            'saved to the `feedback_data` attribute, which is then is pickled '
            'and saved into the database.' % type(self).__name__)

    def __str__(self):
        return '%s(%s)' % (type(self).__name__, self.status)

    # Feedback and visualization
    ok_message = _('*Congratulations!* Your response is correct!')
    wrong_message = _('I\'m sorry, your response is wrong.')
    partial_message = _('Your answer is partially correct: you achieved only '
                        '%(grade)d%% of the total grade.')

    def html_feedback(self):
        """
        A string of html source representing the feedback.
        """

        if self.is_done:
            data = {'grade': (self.grade or 0)}

            if self.grade == 100:
                return markdown(self.ok_message)
            elif not self.grade:
                return markdown(self.wrong_message)
            else:
                return markdown(aself.partial_message % data)
        else:
            return markdown(_('Your response has not been graded yet!'))

    # Permissions
    def can_edit(self, user):
        return False

    def can_view(self, user):
        return user == self.user
예제 #7
0
class CodingIoQuestion(Question, models.StatusModel):
    """
    CodeIo questions evaluate source code and judge them by checking if the
    inputs and corresponding outputs match an expected pattern.
    """

    STATUS_INVALID = 'invalid'
    STATUS_UGLY = 'ugly'
    STATUS_DIRTY = 'dirty'
    STATUS_VALID = 'valid'
    STATUS_INCOMPLETE = 'incomplete'
    STATUS = models.Choices(
        (STATUS_INCOMPLETE, _('is not yet fully initialized')),
        (STATUS_INVALID, _('no valid answers')),
        (STATUS_UGLY, _('inconsistent answers')),
        (STATUS_DIRTY, _('some valid answers')),
        (STATUS_VALID, _('valid')),
    )
    iospec_size = models.PositiveIntegerField(
        _('number of iospec template expansions'),
        default=0,
        blank=True,
        help_text=_('The desired number of test cases that will be computed'
                    'after comparing the iospec template with the answer key.'
                    'This is only a suggested value and will only be applied if'
                    'the response template uses input commands to generate'
                    'random input.'),
    )
    iospec_source = models.TextField(
        _('response template'),
        blank=True,
        help_text=_('Template used to grade I/O responses. See '
                    'http://pythonhosted.org/iospec for a complete reference '
                    'on the template format.'),
    )
    timeout = models.FloatField(
            _('timeout in seconds'),
            blank=True,
            default=5.0,
            help_text=_('Defines the maximum runtime the grader will spend '
                        'evaluating each test case.'),
    )
    tracker = FieldTracker()

    @property
    def iospec(self):
        """The IoSpec structure corresponding to the iospec_source."""

        return parse_iospec(self.iospec_source)

    @property
    def hash(self):
        """The hash for the iospec_source string. This hash is compared to a
        hash registered to each answer key to check if it has the most current
        iospec data."""

        return md5hash(self.iospec_source + str(self.iospec_size))

    @property
    def is_answer_key_complete(self):
        """Return True if an answer key exists for all programming languages."""

        refs = self.is_answer_keys.values('language__ref', flatten=True)
        all_refs = ProgrammingLanguage.objects.values('ref', flatten=True)
        return set(all_refs) == set(refs)

    class Meta:
        app_label = 'cs_questions'
        verbose_name = _('input/output question')
        verbose_name_plural = _('input/output questions')

    # Importing and exporting
    @classmethod
    def from_markio(cls, source, commit=None, return_keys=False):
        """Creates a CodingIoQuestion object from a Markio object r source
        string and saves the resulting question in the database.

        This function can run without touching the database if the markio file
        does not define any information that should be saved in an answer key.

        Args:
            source:
                A string with the Markio source code.
            commit (bool):
                If True (default), saves resulting question in the database.
            return_keys (bool):
                If True, also return a dictionary mapping language references
                to answer keys.

        Returns:
            question:
                A question object.
            [answer_keys]:
                A map from language references to :class:`AnswerKeyItem`
                objects.
        """

        import markio

        if isinstance(source, markio.Markio):
            data = source
        else:
            data = markio.parse_string(source)

        # Create question object from parsed markio data
        question = CodingIoQuestion(
            title=data.title,
            author_name=data.author,
            timeout=data.timeout,
            short_description=data.short_description,
            long_description=data.description,
            iospec_source=data.tests,
        )
        saving(question, commit)

        # Add answer keys
        answer_keys = {}
        for (lang, answer_key) in data.answer_key.items():
            language = programming_language(lang)
            key = saving(CodingIoAnswerKey(question=question,
                                           language=language,
                                           source=answer_key), commit)
            answer_keys[lang] = key
        for (lang, placeholder) in data.placeholder.items():
            if placeholder is None:
                continue
            try:
                answer_keys[lang].placeholder = placeholder
                saving(answer_keys[lang], commit, update_fields=['placeholder'])
            except KeyError:
                language = ProgrammingLanguage.objects.get(lang)
                key = CodingIoAnswerKey(question=question,
                                        language=language,
                                        placeholder=placeholder)
                saving(key, commit)

        # Question is done!
        if return_keys:
            answer_keys = {key.language.ref: key
                           for key in answer_keys.values()}
            return question, answer_keys
        return question

    @classmethod
    def from_data(cls, source):
        """Return a new CodingIoQuestion instance from a string of Markio
        data. This API is used by the HasUploadMixin in the create view."""

        return cls.from_markio(source.decode('utf8'))

    def to_markio(self):
        """Serializes question into a string of Markio source."""

        import markio

        tree = markio.Markio(
            title=self.name,
            author=self.author_name,
            timeout=self.timeout,
            short_description=self.short_description,
            description=self.long_description,
            tests=self.iospec_source,
        )

        for key in self.answer_keys.all():
            tree.add_answer_key(key.source, key.language.ref)
            tree.add_placeholder(key.placeholder, key.language.ref)

        return tree.source()

    def to_data(self, type=None):
        """Render question as a Markio source. This API is used by the
        DetailView in the CRUD interface."""

        if type in (None, 'markio'):
            return self.to_markio()
        else:
            return NotImplemented

    # Validation
    def update(self, save=True, validate=True):
        """Update and validate all answer keys."""

        exception = None
        expanded_sources = {}
        invalid_languages = set()
        valid_languages = set()

        def validate_answer_keys():
            nonlocal exception

            for key in self.answer_keys.all():
                try:
                    if not key.is_update:
                        key.question = self
                        key.update(save, validate)
                    if not key.is_valid:
                        invalid_languages.add(key.language.ref)
                    elif key.source:
                        valid_languages.add(key.language.ref)
                except key.ValidationError as ex:
                    exception = ex
                    exception.__traceback__ = exception.__traceback__
                if key.iospec_source:
                    expanded_sources[key.language.ref] = key.iospec_source

            if len(expanded_sources) == 0:
                self.status = 'invalid'
            elif len(set(expanded_sources.values())) != 1:
                self.status = 'ugly'
            elif invalid_languages:
                if valid_languages:
                    self.status = 'dirty'
                else:
                    self.status = 'invalid'
            else:
                self.status = 'valid'

        # Save fields if rollback is necessary
        iospec_source = self.iospec_source
        iospec_size = self.iospec_size
        has_changed = (self.tracker.has_changed('iospec_source') or
                       self.tracker.has_changed('iospec_size'))

        # If fields had changed, update and restore original values
        if has_changed:
            self.save(update_fields=['iospec_source', 'iospec_size'])
            try:
                validate_answer_keys()
            finally:
                if not save:
                    self.iospec_size = iospec_size
                    self.iospec_source = iospec_source
                    self.save(update_fields=['iospec_source', 'iospec_size'])
        else:
            validate_answer_keys()

        # Force save if necessary
        if save:
            self.save()

    def update_keys(self):
        """Update all keys that were not updated."""

        for key in self.answer_keys.exclude(iospec_hash=self.hash):
            key.update(validate=False)

    def get_validation_errors(self, lang=None, test_iospec=True):
        """Raise ValueError if some answer key is invalid or produce
         invalid iospec expansions.

         Return a valid iospec tree expansion or None if no expansion was
         possible (e.g., by the lack of source code in the answer key)."""

        # It cannot be valid if the iospec source does not not parse
        if test_iospec:
            try:
                tree = parse_iospec(self.iospec)
            except SyntaxError as ex:
                raise ValueError('invalid iospec syntax: %s' % ex)

        # Expand to all langs if lang is not given
        if lang is None:
            keys = self.answer_keys.exclude(source='')
            langs = keys.values_list('language', flat=True)
            expansions = [self.is_valid(lang, test_iospec=False)
                          for lang in langs]
            if not expansions:
                return None
            if iospec.ioequal(expansions):
                return expansions[0]

        # Test an specific language
        if isinstance(lang, str):
            lang = ProgrammingLanguage.get(ref=lang)
        try:
            key = self.answer_keys.get(language=lang)
        except self.DoesNotExist:
            return None

        if key.source:
            result = run_code(key.source, key, lang=lang.ref)
            if result.has_errors():
                raise result.get_exception()
            return result
        else:
            return None

    # Other API
    def get_placeholder(self, lang):
        """Return the placeholder text for the given language."""

        if isinstance(lang, str):
            try:
                lang = ProgrammingLanguage.objects.get(ref=lang)
            except ProgrammingLanguage.DoesNotExist:
                return ''
        try:
            key = self.answer_keys.get(language=lang)
            return key.placeholder
        except CodingIoAnswerKey.DoesNotExist:
            return ''

    def grade(self, response, error=None):
        """Grade the given response object and return the corresponding
        feedback object."""

        try:
            key = self.answer_keys.get(language=response.language)
            key.assure_is_valid(error)
            iospec_data = key.iospec
        except CodingIoAnswerKey.DoesNotExist:
            self.update_keys()

            # Get all sources
            iospec_sources = self.answer_keys.filter(is_valid=True)\
                .values_list('iospec_source', flat=True)
            iospec_sources = set(iospec_sources)

            # Check if there is only a single distinct source
            if not iospec_sources:
                iospec_data = self.iospec.copy()
                iospec_data.expand_inputs()
                if not all(isinstance(x, SimpleTestCase) for x in iospec_data):
                    raise (
                        error or
                        CodingIoAnswerKey.ValidationError(iospec_data.pformat())
                    )
            elif len(iospec_sources) == 1:
                iospec_data = parse_iospec(next(iter(iospec_sources)))
            else:
                raise error or CodingIoAnswerKey.ValidationError(iospec_sources)

        # Construct ejudge feedback object
        lang = response.language.ref
        source = response.source
        return grade_code(source, iospec_data, lang=lang)
예제 #8
0
class Activity(models.InheritableModel):
    """Represents a gradable activity inside a course. It can be scheduled to
    be done in class or as a homework assignment.

    Each concrete activity is represented by a different subclass.
    """
    STATUS_OPEN = 'open'
    STATUS_CLOSED = 'closed'
    STATUS_VISIBLE = 'visible'
    STATUS_DRAFT = 'draft'
    STATUS = models.Choices(
        (STATUS_DRAFT, _('draft')),
        (STATUS_OPEN, _('open')),
        (STATUS_CLOSED, _('closed')),
        (STATUS_VISIBLE, _('visible')),
    )
    status = models.StatusField(
        _('status'),
        help_text=_('Only open activities will be visible and active to all '
                    'students.'),
    )
    published_at = models.MonitorField(
        _('date of publication'),
        monitor='status',
        when=['open']
    )
    icon_src = models.CharField(max_length=50, blank=True)
    name = models.CharField(max_length=200)
    short_description = models.CharField(max_length=140, blank=True)
    long_description = models.TextField(blank=True)
    course = models.ForeignKey('cs_courses.Course', related_name='activities')
    parent = models.ForeignKey(
        'self',
        blank=True, null=True,
        related_name='children'
    )

    _default_material_icon = 'help_underline'

    @property
    def material_icon(self):
        if self.icon_src.startswith('material:'):
            return self.icon_src[9:]
        return self._default_material_icon

    class Meta:
        verbose_name = _('activity')
        verbose_name_plural = _('activities')

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return reverse('activity:detail', kwargs={'pk': self.pk})

    def can_edit(self, user):
        """Return True if user has permissions to edit activity."""

        return user == self.course.teacher

    def can_view(self, user):
        """Return True if user has permission to view activity."""

        return (
            user == self.course.teacher or
            user in self.course.staff.all() or
            user in self.course.students.all()
        )
예제 #9
0
class Progress(CommitMixin,
               models.CopyMixin,
               models.StatusModel,
               models.TimeStampedModel,
               models.PolymorphicModel):
    """
    When an user starts an activity it opens a Progress object which control
    all submissions to the given activity.

    The Progress object also manages individual submissions that may span
    several http requests.
    """

    class Meta:
        unique_together = [('user', 'activity_page')]
        verbose_name = _('student progress')
        verbose_name_plural = _('student progress list')

    STATUS_OPENED = 'opened'
    STATUS_CLOSED = 'closed'
    STATUS_INCOMPLETE = 'incomplete'
    STATUS_WAITING = 'waiting'
    STATUS_INVALID = 'invalid'
    STATUS_DONE = 'done'

    STATUS = models.Choices(
        (STATUS_OPENED, _('opened')),
        (STATUS_CLOSED, _('closed')),
    )

    user = models.ForeignKey(models.User, on_delete=models.CASCADE)
    activity_page = models.ForeignKey(models.Page, on_delete=models.CASCADE)
    final_grade_pc = models.DecimalField(
        _('final score'),
        max_digits=6, decimal_places=3, default=Decimal,
        help_text=_(
            'Final grade given to considering all submissions, penalties, etc.'
        ),
    )
    given_grade_pc = models.DecimalField(
        _('grade'),
        max_digits=6, decimal_places=3, default=Decimal,
        help_text=_('Final grade before applying any modifier.'),
    )
    finished = models.DateTimeField(blank=True, null=True)
    best_submission = models.ForeignKey('Submission', blank=True, null=True,
                                        related_name='+')
    points = models.IntegerField(default=0)
    score = models.IntegerField(default=0)
    stars = models.FloatField(default=0.0)
    is_correct = models.BooleanField(default=bool)
    has_submissions = models.BooleanField(default=bool)
    has_feedback = models.BooleanField(default=bool)
    has_post_tests = models.BooleanField(default=bool)
    objects = ProgressManager()

    #: The number of submissions
    num_submissions = property(lambda x: x.submissions.count())

    #: Specific activity reference
    activity = property(lambda x: x.activity_page.specific)
    activity_id = property(lambda x: x.activity_page_id)

    #: Has progress mixin interface
    username = property(lambda x: x.user.username)

    def __repr__(self):
        return '<%s: %s>' % (self.__class__.__name__, self)

    def __str__(self):
        tries = self.num_submissions
        user = self.user
        activity = self.activity
        grade = '%s pts' % (self.final_grade_pc or 0)
        fmt = '%s by %s (%s, %s tries)'
        return fmt % (activity, user, grade, tries)

    def submit(self, request, payload, recycle=True, commit=True):
        """
        Creates new submission.

        Args:
            recycle:
                If True, recycle submission objects with the same content as the
                current submission. If a submission exists with the same content
                as the current submission, it simply returns the previous
                submission. If recycled, sets the submission.recycled to True.
        """

        submission_class = self.activity.submission_class
        submission = submission_class(progress=self, **payload)
        submission.ip_address = get_ip(request)
        submission.hash = submission.compute_hash()
        submission.full_clean()

        # Then check if any submission is equal to some past submission and
        # then recycle it
        recyclable = submission_class.objects.recyclable(submission)
        recyclable = recyclable if recycle else ()
        for possibly_equal in recyclable:
            if submission.is_equal(possibly_equal):
                possibly_equal.recycled = True
                possibly_equal.bump_recycles()
                return possibly_equal
        else:
            return submission.commit(commit)

    def register_feedback(self, feedback, commit=True):
        """
        This method is called after a submission is graded and produces a
        feedback.
        """

        submission = feedback.submission

        # Check if it is the best submission
        grade = feedback.given_grade_pc
        if (self.best_submission is None or
                self.best_submission.feedback.given_grade_pc < grade):
            self.best_submission = submission

        # Update grades for activity considering past submissions
        self.update_grades_from_feedback(feedback)
        self.commit(commit)

    def update_grades_from_feedback(self, feedback):
        """
        Update grades from the current progress object from the given feedback.
        """

        # Update grades, keeping always the best grade
        if self.given_grade_pc < (feedback.given_grade_pc or 0):
            self.given_grade_pc = feedback.given_grade_pc
        if self.final_grade_pc < feedback.final_grade_pc:
            self.final_grade_pc = feedback.final_grade_pc

        # Update the is_correct field
        self.is_correct = self.is_correct or feedback.is_correct
예제 #10
0
class Submission(ResponseDataMixin, FeedbackDataMixin, models.CopyMixin,
                 models.StatusModel, models.TimeStampedModel,
                 models.PolymorphicModel):
    """
    Represents a student's simple submission in response to some activity.

    Submissions can be in 4 different states:

    pending:
        The response has been sent, but was not graded. Grading can be manual or
        automatic, depending on the activity.
    waiting:
        Waiting for manual feedback.
    incomplete:
        For long-term activities, this tells that the student started a response
        and is completing it gradually, but the final response was not achieved
        yet.
    invalid:
        The response has been sent, but contains malformed data.
    done:
        The response was graded and evaluated and it initialized a feedback
        object.

    A response always starts at pending status. We can request it to be graded
    by calling the :func:`Response.autograde` method. This method must raise
    an InvalidResponseError if the response is invalid or ManualGradingError if
    the response subclass does not implement automatic grading.
    """
    class Meta:
        verbose_name = _('submission')
        verbose_name_plural = _('submissions')

    # Feedback messages
    MESSAGE_OK = _('*Congratulations!* Your response is correct!')
    MESSAGE_OK_WITH_PENALTIES = _(
        'Your response is correct, but you did not achieved the maximum grade.'
    )
    MESSAGE_WRONG = _('I\'m sorry, your response is wrong.')
    MESSAGE_PARTIAL = _(
        'Your answer is partially correct: you achieved only %(grade)d%% of '
        'the total grade.')
    MESSAGE_NOT_GRADED = _('Your response has not been graded yet!')

    # Status
    STATUS_PENDING = 'pending'
    STATUS_INCOMPLETE = 'incomplete'
    STATUS_WAITING = 'waiting'
    STATUS_INVALID = 'invalid'
    STATUS_DONE = 'done'

    # Fields
    STATUS = models.Choices(
        (STATUS_PENDING, _('pending')),
        (STATUS_INCOMPLETE, _('incomplete')),
        (STATUS_WAITING, _('waiting')),
        (STATUS_INVALID, _('invalid')),
        (STATUS_DONE, _('done')),
    )

    response = models.ParentalKey(
        'Response',
        related_name='submissions',
    )
    given_grade = models.DecimalField(
        _('percentage of maximum grade'),
        help_text=_(
            'This grade is given by the auto-grader and represents the grade '
            'for the response before accounting for any bonuses or penalties.'
        ),
        max_digits=6,
        decimal_places=3,
        blank=True,
        null=True,
    )
    final_grade = models.DecimalField(
        _('final grade'),
        help_text=_(
            'Similar to given_grade, but can account for additional factors '
            'such as delay penalties or for any other reason the teacher may '
            'want to override the student\'s grade.'),
        max_digits=6,
        decimal_places=3,
        blank=True,
        null=True,
    )
    manual_override = models.BooleanField(default=False)
    points = models.IntegerField(default=0)
    score = models.IntegerField(default=0)
    stars = models.FloatField(default=0)
    objects = SubmissionManager()

    # Status properties
    is_done = property(lambda x: x.status == x.STATUS_DONE)
    is_pending = property(lambda x: x.status == x.STATUS_PENDING)
    is_waiting = property(lambda x: x.status == x.STATUS_WAITING)
    is_invalid = property(lambda x: x.status == x.STATUS_INVALID)

    @property
    def is_correct(self):
        if self.given_grade is None:
            raise AttributeError('accessing attribute of non-graded response.')
        else:
            return self.given_grade == 100

    # Delegate properties
    activity = delegate_to('response')
    activity_id = delegate_to('response')
    activity_page = delegate_to('response')
    activity_page_id = delegate_to('response')
    user = delegate_to('response')
    user_id = delegate_to('response')
    stars_total = delegate_to('activity')
    points_total = delegate_to('activity')

    @classmethod
    def response_data_hash(cls, response_data):
        """
        Computes a hash for the response_data attribute.

        Data must be given as a JSON-like structure or as a string of JSON data.
        """

        if response_data:
            if isinstance(response_data, str):
                data = response_data
            else:
                data = json.dumps(response_data, default=json_default)
            return md5hash(data)
        return ''

    def __init__(self, *args, **kwargs):
        # Django is loading object from the database -- we step out the way
        if args and not kwargs:
            super().__init__(*args, **kwargs)
            return

        # We create the response_data and feedback_data manually always using
        # copies of passed dicts. We save these variables here, init object and
        # then copy this data to the initialized dictionaries
        response_data = kwargs.pop('response_data', None) or {}
        feedback_data = kwargs.pop('feedback_data', None) or {}

        # This part makes a Submission instance initialize from a user +
        # activity instead of requiring a response object. The response is
        # automatically created on demand.
        user = kwargs.pop('user', None)
        if 'response' in kwargs and user and user != kwargs['response'].user:
            response_user = kwargs['response'].user
            raise ValueError('Inconsistent user definition: %s vs. %s' %
                             (user, response_user))
        elif 'response' not in kwargs and user:
            try:
                activity = kwargs.pop('activity')
            except KeyError:
                raise TypeError(
                    '%s objects bound to a user must also provide an '
                    'activity parameter.' % type(self).__name__)
            else:
                # User-bound constructor tries to obtain the response object by
                # searching for an specific (user, activity) tuple.
                response, created = Response.objects.get_or_create(
                    user=user, activity=activity)
                kwargs['response'] = response

        if 'context' in kwargs or 'activity' in kwargs:
            raise TypeError(
                'Must provide an user to instantiate a bound submission.')
        super().__init__(*args, **kwargs)

        # Now that we have initialized the submission, we fill the data
        # passed in the response_data and feedback_data dictionaries.
        self.response_data = dict(self.response_data or {}, **response_data)
        self.feedback_data = dict(self.response_data or {}, **feedback_data)

    def __str__(self):
        if self.given_grade is None:
            grade = self.status
        else:
            grade = '%s pts' % self.final_grade
        user = self.user
        activity = self.activity
        name = self.__class__.__name__
        return '<%s: %s by %s (%s)>' % (name, activity, user, grade)

    def __html__(self):
        """
        A string of html source representing the feedback.
        """

        if self.is_done:
            data = {'grade': (self.final_grade or 0)}

            if self.final_grade == 100:
                return markdown(self.MESSAGE_OK)
            elif self.given_grade == 100:
                return markdown(self.ok_with_penalties_message)
            elif not self.given_grade:
                return markdown(self.MESSAGE_WRONG)
            else:
                return markdown(self.MESSAGE_PARTIAL % data)
        else:
            return markdown(self.MESSAGE_NOT_GRADED)

    def save(self, *args, **kwargs):
        if not self.response_hash:
            self.response_hash = self.response_hash_from_data(
                self.response_hash)
        super().save(*args, **kwargs)

    def final_points(self):
        """
        Return the amount of points awarded to the submission after
        considering all penalties and bonuses.
        """

        return self.points

    def final_stars(self):
        """
        Return the amount of stars awarded to the submission after
        considering all penalties and bonuses.
        """

        return self.stars

    def given_stars(self):
        """
        Compute the number of stars that should be awarded to the submission
        without taking into account bonuses and penalties.
        """

        return self.stars_total * (self.given_grade / 100)

    def given_points(self):
        """
        Compute the number of points that should be awarded to the submission
        without taking into account bonuses and penalties.
        """

        return int(self.points_total * (self.given_grade / 100))

    def feedback(self, commit=True, force=False, silent=False):
        """
        Return the feedback object associated to the given response.

        This method may trigger the autograde() method, if grading was not
        performed yet. If you want to defer database access, call it with
        commit=False to prevent saving any modifications to the response object
        to the database.

        The commit, force and silent arguments have the same meaning as in
        the :func:`Submission.autograde` method.
        """

        if self.status == self.STATUS_PENDING:
            self.autograde(commit=commit, force=force, silent=silent)
        elif self.status == self.STATUS_INVALID:
            raise self.feedback_data
        elif self.status == self.STATUS_WAITING:
            return None
        return self.feedback_data

    def autograde(self, commit=True, force=False, silent=False):
        """
        Performs automatic grading.

        Response subclasses must implement the autograde_compute() method in
        order to make automatic grading work. This method may write any
        relevant information to the `feedback_data` attribute and must return
        a numeric value from 0 to 100 with the given automatic grade.

        Args:
            commit:
                If false, prevents saving the object when grading is complete.
                The user must save the object manually after calling this
                method.
            force:
                If true, force regrading the item even if it has already been
                graded. The default behavior is to ignore autograde from a
                graded submission.
            silent:
                Prevents the submission_graded_signal from triggering in the
                end of a successful grading.
        """

        if self.status == self.STATUS_PENDING or force:
            # Evaluate grade using the autograde_value() method of subclass.
            try:
                value = self.autograde_value()
            except self.InvalidSubmissionError as ex:
                self.status = self.STATUS_INVALID
                self.feedback_data = ex
                self.given_grade = self.final_grade = decimal.Decimal(0)
                if commit:
                    self.save()
                raise

            # If no value is returned, change to STATUS_WAITING. This probably
            # means that response is partial and we need other submissions to
            # complete the final response
            if value is None:
                self.status = self.STATUS_WAITING

            # A regular submission has a decimal grade value. We save it and
            # change state to STATUS_DONE
            else:
                self.given_grade = decimal.Decimal(value)
                if self.final_grade is None:
                    self.final_grade = self.given_grade
                self.status = self.STATUS_DONE

            # Commit results
            if commit and self.pk:
                self.save(update_fields=[
                    'status', 'feedback_data', 'given_grade', 'final_grade'
                ])
            elif commit:
                self.save()

            # If STATUS_DONE, we submit the submission_graded signal.
            if self.status == self.STATUS_DONE:
                self.stars = self.given_stars()
                self.points = self.given_points()
                self.response.register_submission(self)
                if not silent:
                    submission_graded_signal.send(
                        Submission,
                        submission=self,
                        given_grade=self.given_grade,
                        automatic=True,
                    )

        elif self.status == self.STATUS_INVALID:
            raise self.feedback_data

    def manual_grade(self, grade, commit=True, raises=False, silent=False):
        """
        Saves result of manual grading.

        Args:
            grade (number):
                Given grade, as a percentage value.
            commit:
                If false, prevents saving the object when grading is complete.
                The user must save the object manually after calling this
                method.
            raises:
                If submission has already been graded, raises a GradingError.
            silent:
                Prevents the submission_graded_signal from triggering in the
                end of a successful grading.
        """

        if self.status != self.STATUS_PENDING and raises:
            raise GradingError('Submission has already been graded!')

        raise NotImplementedError('TODO')

    def autograde_value(self):
        """
        This method should be implemented in subclasses.
        """

        raise ImproperlyConfigured(
            'Response subclass %r must implement the autograde_value().'
            'This method should perform the automatic grading and return the '
            'resulting grade. Any additional relevant feedback data might be '
            'saved to the `feedback_data` attribute, which is then is pickled '
            'and saved into the database.' % type(self).__name__)

    def regrade(self, method, commit=True):
        """
        Recompute the grade for the given submission.

        If status != 'done', it simply calls the .autograde() method. Otherwise,
        it accept different strategies for updating to the new grades:
            'update':
                Recompute the grades and replace the old values with the new
                ones. Only saves the submission if the feedback_data or the
                given_grade attributes change.
            'best':
                Only update if the if the grade increase.
            'worst':
                Only update if the grades decrease.
            'best-feedback':
                Like 'best', but updates feedback_data even if the grades
                change.
            'worst-feedback':
                Like 'worst', but updates feedback_data even if the grades
                change.

        Return a boolean telling if the regrading was necessary.
        """
        if self.status != self.STATUS_DONE:
            return self.autograde()

        # We keep a copy of the state, if necessary. We only have to take some
        # action if the state changes.
        def rollback():
            self.__dict__.clear()
            self.__dict__.update(state)

        state = self.__dict__.copy()
        self.autograde(force=True, commit=False)

        # Each method deals with the new state in a different manner
        if method == 'update':
            if state != self.__dict__:
                if commit:
                    self.save()
                return False
            return True
        elif method in ('best', 'best-feedback'):
            if self.given_grade <= state.get('given_grade', 0):
                new_feedback_data = self.feedback_data
                rollback()
                if new_feedback_data != self.feedback_data:
                    self.feedback_data = new_feedback_data
                    if commit:
                        self.save()
                    return True
                return False
            elif commit:
                self.save()
            return True

        elif method in ('worst', 'worst-feedback'):
            if self.given_grade >= state.get('given_grade', 0):
                new_feedback_data = self.feedback_data
                rollback()
                if new_feedback_data != self.feedback_data:
                    self.feedback_data = new_feedback_data
                    if commit:
                        self.save()
                    return True
                return False
            elif commit:
                self.save()
            return True
        else:
            rollback()
            raise ValueError('invalid method: %s' % method)
예제 #11
0
class Progress(models.CopyMixin, models.StatusModel, models.TimeStampedModel,
               models.PolymorphicModel):
    """
    When an user starts an activity it opens a Progress object which control
    all submissions to the given activity.

    The Progress object also manages individual submissions that may span
    several http requests.
    """
    class Meta:
        unique_together = [('user', 'activity_page')]
        verbose_name = _('student progress')
        verbose_name_plural = _('student progress list')

    STATUS_OPENED = 'opened'
    STATUS_CLOSED = 'closed'
    STATUS_INCOMPLETE = 'incomplete'
    STATUS_WAITING = 'waiting'
    STATUS_INVALID = 'invalid'
    STATUS_DONE = 'done'

    STATUS = models.Choices(
        (STATUS_OPENED, _('opened')),
        (STATUS_CLOSED, _('closed')),
    )

    user = models.ForeignKey(models.User, on_delete=models.CASCADE)
    activity_page = models.ForeignKey(models.Page, on_delete=models.CASCADE)
    final_grade_pc = models.DecimalField(
        _('final score'),
        max_digits=6,
        decimal_places=3,
        default=Decimal,
        help_text=_(
            'Final grade given to considering all submissions, penalties, etc.'
        ),
    )
    given_grade_pc = models.DecimalField(
        _('grade'),
        max_digits=6,
        decimal_places=3,
        default=Decimal,
        help_text=_('Final grade before applying any modifier.'),
    )
    finished = models.DateTimeField(blank=True, null=True)
    best_submission = models.ForeignKey('Submission',
                                        blank=True,
                                        null=True,
                                        related_name='+')
    points = models.IntegerField(default=0)
    score = models.IntegerField(default=0)
    stars = models.FloatField(default=0.0)
    is_correct = models.BooleanField(default=bool)
    has_submissions = models.BooleanField(default=bool)
    has_feedback = models.BooleanField(default=bool)
    has_post_tests = models.BooleanField(default=bool)
    objects = ProgressManager()

    #: The number of submissions
    num_submissions = property(lambda x: x.submissions.count())

    #: Specific activity reference
    activity = property(lambda x: x.activity_page.specific)
    activity_id = property(lambda x: x.activity_page_id)

    #: Has progress mixin interface
    username = property(lambda x: x.user.username)

    def __repr__(self):
        return '<%s: %s>' % (self.__class__.__name__, self)

    def __str__(self):
        tries = self.num_submissions
        user = self.user
        activity = self.activity
        grade = '%s pts' % (self.final_grade_pc or 0)
        fmt = '%s by %s (%s, %s tries)'
        return fmt % (activity, user, grade, tries)

    def __hash__(self):
        return hash(self.id)

    def __eq__(self, other):
        if isinstance(other, Progress):
            if self.pk is None:
                return False
            else:
                return self.pk == other.pk
        return NotImplemented

    def submit(self, request, recycle=True, **kwargs):
        """
        Creates new submission.
        """

        submission_class = self.activity.submission_class
        submission = submission_class(progress=self, **kwargs)
        submission.ip_address = get_ip(request)

        if not recycle:
            submission.save()
            return submission

        # Collect all submissions with the same hash as current one
        recyclable = submission_class.objects\
            .filter(progress=self, hash=submission.compute_hash()) \
            .order_by('created')

        # Then check if any submission is actually equal to the current amongst
        # all candidates
        for possibly_equal in recyclable:
            if submission.is_equal(possibly_equal):
                possibly_equal.recycled = True
                possibly_equal.bump_recycles()
                return possibly_equal
        else:
            submission.save()
            return submission

    def register_feedback(self, feedback):
        """
        This method is called after a submission is graded and produces a
        feedback.
        """

        submission = feedback.submission
        self.update_grades_from_feedback(feedback)

        if not self.activity.has_submissions:
            print('first submission')
            if feedback.is_correct:
                print('first correct submission')

    def update_grades_from_feedback(self, feedback):
        """
        Update grades from the current progress object from the given feedback.
        """

        # Update grades
        if self.given_grade_pc < (feedback.given_grade_pc or 0):
            self.given_grade_pc = feedback.given_grade_pc

        # TODO: decide better update strategy
        if self.final_grade_pc < feedback.final_grade_pc:
            self.final_grade_pc = feedback.final_grade_pc

        # # Register points and stars associated with submission.
        # score_kwargs = {}
        # final_points = feedback.final_points()
        # final_stars = feedback.final_stars()
        # if final_points > self.points:
        #     score_kwargs['points'] = final_points - self.points
        #     self.points = final_points
        # if final_stars > self.stars:
        #     score_kwargs['stars'] = final_stars - self.stars
        #     self.stars = final_stars
        #
        # # If some score has changed, we save the update fields and update the
        # # corresponding UserScore object
        # if score_kwargs:
        #     from codeschool.gamification.models import UserScore
        #     self.save(update_fields=score_kwargs.keys())
        #     score_kwargs['diff'] = True
        #     UserScore.update(self.user, self.activity_page, **score_kwargs)

        # Update the is_correct field
        self.is_correct = self.is_correct or feedback.is_correct
        self.save()

    def update_from_submissions(self,
                                grades=True,
                                score=True,
                                commit=True,
                                refresh=False):
        """
        Update grades and gamification scores for all submissions.

        Args:
            grades, score (bool):
                choose to update final grades and/or final scores.
            commit:
                if True (default), save changes to database.
            refresh:
                if True (default), recompute grade from scratch.
        """

        submissions = self.submissions.all()
        if refresh and submissions.count():
            first = submissions.first()
            if grades:
                self.final_grade_pc = first.given_grade_pc
                self.given_grade_pc = first.given_grade_pc
            if score:
                self.points = first.points
                self.stars = first.stars
                self.score = first.score

        for submission in submissions:
            if grades:
                submission.update_response_grades(commit=False)
            if score:
                submission.update_response_score(commit=False)

        if commit:
            self.save()

    def regrade(self, method=None, force_update=False):
        """
        Return the final grade for the user using the given method.

        If not method is given, it uses the default grading method for the
        activity.
        """

        activity = self.activity

        # Choose grading method
        if method is None and self.final_grade_pc is not None:
            return self.final_grade_pc
        elif method is None:
            grading_method = activity.grading_method
        else:
            grading_method = GradingMethod.from_name(activity.owner, method)

        # Grade response. We save the result to the final_grade_pc attribute if
        # no explicit grading method is given.
        grade = grading_method.grade(self)
        if method is None and (force_update or self.final_grade_pc is None):
            self.final_grade_pc = grade
        return grade
예제 #12
0
class ResponseItem(models.CopyMixin,
                   models.TimeStampedStatusModel,
                   models.PolymorphicModel):
    """
    Represents a student's response to some activity.

    Response objects have 4 different states:

    pending:
        The response has been sent, but was not graded. Grading can be manual or
        automatic, depending on the activity.
    waiting:
        Waiting for manual feedback.
    incomplete:
        For long-term activities, this tells that the student started a response
        and is completing it gradually, but the final response was not achieved
        yet.
    invalid:
        The response has been sent, but contains malformed data.
    done:
        The response was graded and evaluated and it initialized a feedback
        object.

    A response always starts at pending status. We can request it to be graded
    by calling the :func:`Response.autograde` method. This method must raise
    an InvalidResponseError if the response is invalid or ManualGradingError if
    the response subclass does not implement automatic grading.
    """

    class Meta:
        verbose_name = _('response')
        verbose_name_plural = _('responses')

    STATUS_PENDING = 'pending'
    STATUS_INCOMPLETE = 'incomplete'
    STATUS_WAITING = 'waiting'
    STATUS_INVALID = 'invalid'
    STATUS_DONE = 'done'
    STATUS = models.Choices(
        (STATUS_PENDING, _('pending')),
        (STATUS_INCOMPLETE, _('incomplete')),
        (STATUS_WAITING, _('waiting')),
        (STATUS_INVALID, _('invalid')),
        (STATUS_DONE, _('done')),
    )

    response = models.ParentalKey(
        'Response',
        verbose_name=_('response'),
        related_name='items',
    )
    feedback_data = models.JSONField(
        null=True,
        blank=True,
    )
    response_data = models.JSONField(
        null=True,
        blank=True,
    )
    response_hash = models.CharField(
        max_length=32,
        blank=True,
    )
    given_grade = models.DecimalField(
        _('Percentage of maximum grade'),
        help_text=_(
            'This grade is given by the auto-grader and represents the grade '
            'for the response before accounting for any bonuses or penalties.'),
        max_digits=6,
        decimal_places=3,
        blank=True,
        null=True,
    )
    final_grade = models.DecimalField(
        _('Final grade'),
        help_text=_(
            'Similar to given_grade, but can account for additional factors '
            'such as delay penalties or for any other reason the teacher may '
            'want to override the student\'s grade.'),
        max_digits=6,
        decimal_places=3,
        blank=True,
        null=True,
    )
    manual_override = models.BooleanField(
        default=False
    )

    # Status properties
    is_done = property(lambda x: x.status == x.STATUS_DONE)
    is_pending = property(lambda x: x.status == x.STATUS_PENDING)
    is_waiting = property(lambda x: x.status == x.STATUS_WAITING)
    is_invalid = property(lambda x: x.status == x.STATUS_INVALID)

    # Delegate properties
    activity = property(lambda x: x.response.activity.specific)
    user = property(lambda x: x.response.user)
    context = property(lambda x: x.response.context)
    course = property(lambda x: x.activity.course)

    def __init__(self, *args, **kwargs):
        # Django is loading object from the database -- we step out the way
        if args and not kwargs:
            super().__init__(*args, **kwargs)
            return

        # We create the response_data and feedback_data manually always using
        # copies of passed dicts. We save these variables here, init object and
        # then copy this data to the initialized dictionaries
        response_data = kwargs.pop('response_data', None) or {}
        feedback_data = kwargs.pop('feedback_data', None) or {}

        # This part makes a ResponseItem instance initialize from a user +
        # activity + context instead of requiring a response object. The
        # response is automatically created on demand.
        user = kwargs.pop('user', None)
        if user:
            context = kwargs.pop('context', None)
            try:
                activity = kwargs.pop('activity')
            except KeyError:
                raise TypeError(
                    'ReponseItem objects bound to a user must also provide an '
                    'activity parameter.'
                )

            # User-bound constructor tries to obtain the Response object by
            # searching for an specific (user, context, activity) tuple.
            response, created = Response.objects.get_or_create(
                user=user,
                context=context,
                activity=activity
            )
            kwargs['response'] = response

        if 'context' in kwargs or 'activity' in kwargs:
            raise TypeError(
                'Must provide an user to instantiate a bound response item.'
            )
        super().__init__(*args, **kwargs)

        # Now that we have initialized the response item, we fill the data
        # passed in the response_data and feedback_data dictionaries.
        self.response_data = dict(self.response_data or {}, **response_data)
        self.feedback_data = dict(self.response_data or {}, **feedback_data)

    def __str__(self):
        if self.given_grade is None:
            grade = self.status
        else:
            grade = '%s pts' % self.final_grade
        user = self.user
        activity = self.activity
        return '<ResponseItem: %s by %s (%s)>' % (activity, user, grade)

    def save(self, *args, **kwargs):
        if not self.response_hash:
            self.response_hash = self.get_response_hash(self.response_hash)
        super().save(*args, **kwargs)

    def get_feedback_data(self, commit=True):
        """Return the feedback object associated to the given response.

        This method may trigger the autograde() method, if grading was not
        performed yet. If you want to defer database access, call it with
        commit=False to prevent saving any modifications to the response object
        to the database.
        """

        if self.status == self.STATUS_PENDING:
            self.autograde(commit)
        elif self.status == self.STATUS_INVALID:
            raise self.feedback_data
        elif self.status == self.STATUS_WAITING:
            return None
        return self.feedback_data

    def autograde(self, commit=True, force=False, silent=False):
        """
        Performs automatic grading.

        Response subclasses must implement the autograde_compute() method in
        order to make automatic grading work. This method may write any
        relevant information to the `feedback_data` attribute and must return
        a numeric value from 0 to 100 with the given automatic grade.

        Args:
            commit:
                If false, prevents saving the object when grading is complete.
                The user must save the object manually after calling this
                method.
            force:
                If true, force regrading the item even if it has already been
                graded.
            silent:
                Prevents the autograde_signal from triggering in the end of
                a successful autograde.
        """

        if self.status == self.STATUS_PENDING or force:
            try:
                value = self.autograde_compute()
            except self.InvalidResponseError as ex:
                self.status = self.STATUS_INVALID
                self.feedback_data = ex
                self.given_grade = self.final_grade = decimal.Decimal(0)
                if commit:
                    self.save()
                raise

            if value is None:
                self.status = self.STATUS_WAITING
            else:
                self.given_grade = decimal.Decimal(value)
                if self.final_grade is None:
                    self.final_grade = self.given_grade
                self.status = self.STATUS_DONE
                if not silent:
                    autograde_signal.send(
                        self.__class__,
                        response_item=self,
                        given_grade=self.given_grade
                    )
            if commit and self.pk:
                self.save(update_fields=['status', 'feedback_data',
                                         'given_grade', 'final_grade'])
            elif commit:
                self.save()

        elif self.status == self.STATUS_INVALID:
            raise self.feedback_data

    def autograde_compute(self):
        """This method should be implemented in subclasses."""

        raise ImproperlyConfigured(
            'Response subclass %r must implement the autograde_compute().'
            'This method should perform the automatic grading and return the '
            'resulting grade. Any additional relevant feedback data might be '
            'saved to the `feedback_data` attribute, which is then is pickled '
            'and saved into the database.' % type(self).__name__
        )

    def regrade(self, method, commit=True):
        """
        Recompute the grade for the given response item.

        If status != 'done', it simply calls the .autograde() method. Otherwise,
        it accept different strategies for updating to the new grades:
            'update':
                Recompute the grades and replace the old values with the new
                ones. Only saves the response item if the feedback_data or the
                given_grade attributes change.
            'best':
                Only update if the if the grade increase.
            'worst':
                Only update if the grades decrease.
            'best-feedback':
                Like 'best', but updates feedback_data even if the grades
                change.
            'worst-feedback':
                Like 'worst', but updates feedback_data even if the grades
                change.

        Return a boolean telling if the regrading was necessary.
        """
        if self.status != self.STATUS_DONE:
            return self.autograde()

        # We keep a copy of the state, if necessary. We only have to take some
        # action if the state changes.
        def rollback():
            self.__dict__.clear()
            self.__dict__.update(state)

        state = self.__dict__.copy()
        self.autograde(force=True, commit=False)

        # Each method deals with the new state in a different manner
        if method == 'update':
            if state != self.__dict__:
                if commit:
                    self.save()
                return False
            return True
        elif method in ('best', 'best-feedback'):
            if self.given_grade <= state.get('given_grade', 0):
                new_feedback_data = self.feedback_data
                rollback()
                if new_feedback_data != self.feedback_data:
                    self.feedback_data = new_feedback_data
                    if commit:
                        self.save()
                    return True
                return False
            elif commit:
                self.save()
            return True

        elif method in ('worst', 'worst-feedback'):
            if self.given_grade >= state.get('given_grade', 0):
                new_feedback_data = self.feedback_data
                rollback()
                if new_feedback_data != self.feedback_data:
                    self.feedback_data = new_feedback_data
                    if commit:
                        self.save()
                    return True
                return False
            elif commit:
                self.save()
            return True
        else:
            rollback()
            raise ValueError('invalid method: %s' % method)

    @classmethod
    def get_response_hash(cls, response_data):
        """
        Computes a hash for the response_data attribute.
        """

        if response_data:
            data = json.dumps(response_data, default=json_default)
            return md5hash(data)
        return ''

    # Feedback and visualization
    ok_message = _('*Congratulations!* Your response is correct!')
    ok_with_penalties = _('Your response is correct, but you did not achieved '
                          'the maximum grade.')
    wrong_message = _('I\'m sorry, your response is wrong.')
    partial_message = _('Your answer is partially correct: you achieved only '
                        '%(grade)d%% of the total grade.')

    def html_feedback(self):
        """
        A string of html source representing the feedback.
        """

        if self.is_done:
            data = {'grade': (self.final_grade or 0)}

            if self.final_grade == 100:
                return markdown(self.ok_message)
            elif self.given_grade == 100:
                return markdown(self.ok_with_penalties_message)
            elif not self.given_grade:
                return markdown(self.wrong_message)
            else:
                return markdown(self.partial_message % data)
        else:
            return markdown(_('Your response has not been graded yet!'))

    # Permissions
    def can_edit(self, user):
        return False

    def can_view(self, user):
        return user == self.user
예제 #13
0
class FriendshipStatus(models.StatusModel):
    """
    Defines the friendship status between two users.
    """
    class Meta:
        unique_together = ('owner', 'other'),

    STATUS_NONE = 'none'
    STATUS_PENDING = 'pending'
    STATUS_FRIEND = 'friend'
    STATUS_UNFRIEND = 'unfriend'
    STATUS_COLLEAGUE = 'colleague'
    STATUS = models.Choices(
        (STATUS_NONE, _('none')), (STATUS_PENDING, _('pending')),
        (STATUS_FRIEND, _('friend')), (STATUS_UNFRIEND, _('unfriend')),
        (STATUS_COLLEAGUE, _('colleague')))
    owner = models.ForeignKey(models.User, related_name='related_users')
    other = models.ForeignKey(models.User,
                              related_name='related_users_as_other')

    def __str__(self):
        return '%s-%s (%s)' % (self.owner, self.other, self.status)

    def clean(self):
        if self.other == self.owner:
            raise ValidationError('owner and other are the same!')

    def save(self, *args, **kwds):
        created = self.id is None
        super().save(*args, **kwds)

        if created:
            reciprocal, created = FriendshipStatus.objects.get_or_create(
                owner=self.other, other=self.owner)

            if not created:
                return

            if self.status == self.STATUS_COLLEAGUE:
                reciprocal.status = self.STATUS_COLLEAGUE
            elif self.status == self.STATUS_FRIEND:
                reciprocal.status = self.STATUS_PENDING

            reciprocal.save()

    def get_reciprocal(self):
        """
        Gets the reciprocal relationship.
        """

        return FriendshipStatus.objects.get(owner=self.other, other=self.owner)

    def request_friendship(self):
        """
        Owner asks other for friendship.

        This usually sets the reciprocal status to 'pending'. This might be
        different if other has blocked owner or if other has already asked for
        friend status.
        """

        self.status = self.STATUS_FRIEND
        self.save(update_fields=['status'])

        reciprocal = self.get_reciprocal()
        if reciprocal.status in (self.STATUS_COLLEAGUE, self.STATUS_NONE):
            reciprocal.status = self.STATUS_PENDING
            reciprocal.save(update_fields=['status'])
        # status PENDING  ==> keeps pending
        # status UNFRIEND ==> keeps unfriended
        # status FRIEND   ==> now both are friends (no change required)

        if reciprocal.status not in (self.STATUS_FRIEND, self.STATUS_UNFRIEND):
            friendship_requested.send(FriendshipStatus,
                                      from_user=self.owner,
                                      to_user=self.other,
                                      relation=self)
예제 #14
0
class Response(models.CopyMixin, models.StatusModel, models.TimeStampedModel,
               models.PolymorphicModel, models.ClusterableModel):
    """
    When an user starts an activity it opens a Session object that controls
    how responses to the given activity will be submitted.

    The session object manages individual response submissions that may span
    several http requests.
    """
    class Meta:
        unique_together = [('user', 'activity_page')]
        verbose_name = _('final response')
        verbose_name_plural = _('final responses')

    STATUS_OPENED = 'opened'
    STATUS_CLOSED = 'closed'
    STATUS_INCOMPLETE = 'incomplete'
    STATUS_WAITING = 'waiting'
    STATUS_INVALID = 'invalid'
    STATUS_DONE = 'done'

    STATUS = models.Choices(
        (STATUS_OPENED, _('opened')),
        (STATUS_CLOSED, _('closed')),
    )

    user = models.ForeignKey(
        models.User,
        related_name='responses',
        on_delete=models.CASCADE,
    )
    activity_page = models.ForeignKey(
        models.Page,
        related_name='responses',
        on_delete=models.CASCADE,
    )
    grade = models.DecimalField(
        _('given grade'),
        max_digits=6,
        decimal_places=3,
        blank=True,
        null=True,
        default=0,
        help_text=_(
            'Grade given to response considering all submissions, penalties, '
            'etc.'),
    )
    finish_time = models.DateTimeField(
        blank=True,
        null=True,
    )
    points = models.IntegerField(default=0)
    score = models.IntegerField(default=0)
    stars = models.FloatField(default=0.0)
    is_finished = models.BooleanField(default=bool)
    is_correct = models.BooleanField(default=bool)
    objects = ResponseManager()

    #: The number of submissions in the current session.
    num_submissions = property(lambda x: x.submissions.count())

    #: Specific activity reference
    activity = property(lambda x: x.activity_page.specific)
    activity_id = property(lambda x: x.activity_page_id)

    @activity.setter
    def activity(self, value):
        self.activity_page = value.page_ptr

    @classmethod
    def _get_response(cls, user, activity):
        """
        Return the response object associated with the given
        user/activity.

        Create a new response object if it does not exist.
        """

        if user is None or activity is None:
            raise TypeError(
                'Response objects must be bound to an user or activity.')

        response, create = Response.objects.get_or_create(user=user,
                                                          activity=activity)
        return response

    def __repr__(self):
        tries = self.num_submissions
        user = self.user
        activity = self.activity
        class_name = self.__class__.__name__
        grade = '%s pts' % (self.grade or 0)
        fmt = '<%s: %s by %s (%s, %s tries)>'
        return fmt % (class_name, activity, user, grade, tries)

    def __str__(self):
        return repr(self)

    def __hash__(self):
        return hash(self.id)

    def __eq__(self, other):
        if isinstance(other, Response):
            if self.pk is None:
                return False
            else:
                return self.pk == other.pk
        return NotImplemented

    def register_submission(self, submission):
        """
        This method is called when a submission is graded.
        """

        assert submission.response_id == self.id

        # Register points and stars associated with submission.
        score_kwargs = {}
        final_points = submission.final_points()
        final_stars = submission.final_stars()
        if final_points > self.points:
            score_kwargs['points'] = final_points - self.points
            self.points = final_points
        if final_stars > self.stars:
            score_kwargs['stars'] = final_stars - self.stars
            self.stars = final_stars

        # If some score has changed, we save the update fields and update the
        # corresponding UserScore object
        if score_kwargs:
            from codeschool.lms.gamification.models import UserScore
            self.save(update_fields=score_kwargs.keys())
            score_kwargs['diff'] = True
            UserScore.update(self.user, self.activity_page, **score_kwargs)

    def regrade(self, method=None, force_update=False):
        """
        Return the final grade for the user using the given method.

        If not method is given, it uses the default grading method for the
        activity.
        """

        activity = self.activity

        # Choose grading method
        if method is None and self.final_grade is not None:
            return self.final_grade
        elif method is None:
            grading_method = activity.grading_method
        else:
            grading_method = GradingMethod.from_name(activity.owner, method)

        # Grade response. We save the result to the final_grade attribute if
        # no explicit grading method is given.
        grade = grading_method.grade(self)
        if method is None and (force_update or self.final_grade is None):
            self.final_grade = grade
        return grade