class FeedbackDataMixin(models.Model): """ Mixin that adds feedback data for several fields stored in a JSON dictionary. """ class Meta: abstract = True feedback_data = models.JSONField( null=True, blank=True, )
class Badge(models.TimeStampedModel, models.PolymorphicModel): """ An abstract badge that marks an accomplishment in a given badge track. """ track = models.ForeignKey(BadgeTrack, related_name='badges') name = models.CharField( _('name'), max_length=200, ) slug = models.CharField(unique=True) description = models.TextField( _('description'), help_text=_( 'A detailed description of the accomplishment required to receive ' 'the badge.'), ) message = models.TextField( _('message'), help_text=_( 'The message displayed when users receive the given badge')) image = models.ImageField( upload_to='gamification/badges/', blank=True, null=True, ) required_achievement = models.PositiveIntegerField( default=0, help_text=_( 'Abstract quantity that associated with linear badge tracks.'), ) level = models.PositiveIntegerField( _('Badge level'), help_text=_( 'The badge level: for linear badge tracks, it defines the ordering' 'between different badges.'), ) extra = models.JSONField(default=dict, ) users = models.ManyToManyField( models.User, through='GivenBadge', related_name='badges', ) def issue_badge(self, user): """ Issue badge for the given user. """ self.users.add(user)
class ResponseDataMixin(models.Model): """ Mixin that adds response data for several fields stored in a JSON dictionary. """ class Meta: abstract = True response_data = models.JSONField( null=True, blank=True, ) response_hash = models.CharField( max_length=32, blank=True, )
class CodingIoFeedback(QuestionFeedback): for_pre_test = models.BooleanField( _('Grading pre-test?'), default=False, help_text=_('True if its grading in the pre-test phase.')) json_feedback = models.JSONField(blank=True, null=True) feedback_status = property(lambda x: x.feedback.status) is_wrong_answer = delegate_to('feedback') is_presentation_error = delegate_to('feedback') is_timeout_error = delegate_to('feedback') is_build_error = delegate_to('feedback') is_runtime_error = delegate_to('feedback') @lazy def feedback(self): if self.json_feedback: return Feedback.from_json(self.json_feedback) else: return None def get_tests(self): """ Return an iospec object with the tests for the current correction. """ if self.for_pre_test: return self.question.get_expanded_pre_tests() else: return self.question.get_expand_post_tests() def get_autograde_value(self): tests = self.get_tests() source = self.submission.source language_ref = self.submission.language.ejudge_ref() feedback = grade_code(source, tests, lang=language_ref, timeout=self.question.timeout) return feedback.grade * 100, {'json_feedback': feedback.to_json()} def render_message(self, **kwargs): return render(self.feedback)
class BadgeTrack(models.PolymorphicModel): """ A badge track represents a single type of action that can give several badges for different levels of accomplishment. """ name = models.CharField( _('name'), max_length=200, ) slug = models.SlugField(unique=True) description = models.TextField( _('description'), help_text=_('A detailed description of the badge track.'), ) extra = models.JSONField(default=dict) badge_class = None @classmethod def instance(cls): """ Returns the default instance for the given track. """ return cls() @classmethod def badge(cls, slug, name, description, message, **kwargs): """ Construct a badge instance in the current track. """ return cls.badge_class(track=cls.instance(), name=name, slug=slug, description=description, message=message, **kwargs)
class FormEntry(models.Model): """ An entry in a form. """ TYPE_STRING, TYPE_TEXT, TYPE_BOOLEAN, TYPE_INT, TYPE_FLOAT, TYPE_DATE = \ range(5) TYPE_CHOICES = [ (TYPE_STRING, _('String')), (TYPE_TEXT, _('Text')), (TYPE_BOOLEAN, _('Boolean')), (TYPE_INT, _('Integer')), (TYPE_FLOAT, _('Numeric')), (TYPE_DATE, _('Date')), ] TYPE_MAP = { TYPE_STRING: str, TYPE_TEXT: str, TYPE_BOOLEAN: bool, TYPE_INT: int, TYPE_FLOAT: float, TYPE_DATE: date, } form = models.ForeignKey( FormQuestion, related_name='entries', on_delete=models.CASCADE, ) name = models.CharField(max_length=30) label = models.CharField(max_length=40) help = models.CharField(max_length=140, help_text=True) type = models.SmallIntegerField(choices=TYPE_CHOICES) default = models.CharField(max_length=40, blank=True) placeholder = models.CharField(max_length=40, blank=True) grader_json = models.JSONField() class Meta: unique_together = [('name', 'form')] @property def py_type(self): return self.TYPE_MAP[self.type] @lazy def grader(self): return answer_from_json(self.grader_json) def clean(self): if self.default and not self._can_have_default(): raise ValidationError({ 'default': _('cannot define default values for this type of value.') }) self._validate_default() if self.placeholder and not self._can_have_placeholder(): raise ValidationError({ 'placeholder': _('cannot define a placeholder for this type of value.') }) def _can_placeholder(self): return self.py_type != bool def _can_have_default(self): return self.py_type in {str, int, float, date} def _validate_default(self): tt = self.py_type data = self.default try: if tt in {int, float}: tt(data) elif tt == date: yy, dd, aa = map(int, date.split('/')) elif tt == bool: if data not in {'true', 'false'}: raise ValueError except (ValueError, IndexError) as ex: raise ValidationError({'default': 'invalid default value'}) return self
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