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()
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)
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()
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()
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
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
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)
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() )
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
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)
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
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
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)
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