class CourseDiplomaDesign(models.Model): USERGROUP = CourseInstance.ENROLLMENT_AUDIENCE course = models.OneToOneField(CourseInstance, on_delete=models.SET_NULL, null=True) availability = models.IntegerField(choices=USERGROUP.choices, default=USERGROUP.EXTERNAL_USERS) logo = models.ImageField(blank=True, null=True, upload_to=build_upload_dir) title = models.TextField(blank=True) body = models.TextField(blank=True) date = models.CharField(max_length=256) signature_name = models.CharField(max_length=256, blank=True) signature_title = models.CharField(max_length=256, blank=True) small_print = models.TextField(blank=True) point_limits = JSONField(blank=True, help_text=( "A list of length 5 where each element is the required points for n:th grade." "The element can be a list of 2-tuples [[difficulty_level_a, points],[difficulty_level_b, points]]." )) pad_points = models.BooleanField(default=False, help_text=( "If difficulty levels are used the lower level can be padded with higher level points." )) exercises_to_pass = models.ManyToManyField(BaseExercise, blank=True) modules_to_pass = models.ManyToManyField(CourseModule, blank=True) class Meta: verbose_name = '设计课程证书' verbose_name_plural = verbose_name def __str__(self): return "CourseDiplomaDesign {} for {}".format(self.pk, str(self.course))
class Roll(models.Model): results = JSONField() timestamp = models.DateTimeField() @staticmethod def get_cache_key(id): return 'roll-%s' % id
class Submission(UrlMixin, models.Model): """ A submission to some course exercise from one or more submitters. """ STATUS = Enum([ ('INITIALIZED', 'initialized', _("Initialized")), ('WAITING', 'waiting', _("In grading")), ('READY', 'ready', _("Ready")), # graded normally ('ERROR', 'error', _("Error")), ('REJECTED', 'rejected', _("Rejected")), # missing fields etc ('UNOFFICIAL', 'unofficial', _("No effect on grading")), # unofficial: graded after the deadline or after exceeding the submission limit ]) submission_time = models.DateTimeField(auto_now_add=True) hash = models.CharField(max_length=32, default=get_random_string) # Relations exercise = models.ForeignKey(exercise_models.BaseExercise, on_delete=models.CASCADE, related_name="submissions") submitters = models.ManyToManyField(UserProfile, related_name="submissions") grader = models.ForeignKey(UserProfile, on_delete=models.SET_NULL, related_name="graded_submissions", blank=True, null=True) # Grading and feedback feedback = models.TextField(blank=True) assistant_feedback = models.TextField(blank=True) status = models.CharField(max_length=32, choices=STATUS.choices, default=STATUS.INITIALIZED) grade = models.IntegerField(default=0) grading_time = models.DateTimeField(blank=True, null=True) late_penalty_applied = PercentField(blank=True, null=True) # Points received from assessment, before scaled to grade service_points = models.IntegerField(default=0) service_max_points = models.IntegerField(default=0) # Additional data submission_data = JSONField(blank=True) grading_data = JSONField(blank=True) meta_data = JSONField(blank=True) objects = SubmissionManager() class Meta: app_label = 'exercise' ordering = ['-id'] def __str__(self): return str(self.id) def ordinal_number(self): return self.submitters.first().submissions.exclude_errors().filter( exercise=self.exercise, submission_time__lt=self.submission_time ).count() + 1 def is_submitter(self, user): return user and user.is_authenticated and \ self.submitters.filter(id=user.userprofile.id).exists() def add_files(self, files): """ Adds the given files to this submission as SubmittedFile objects. @param files: a QueryDict containing files from a POST request """ for key in files: for uploaded_file in files.getlist(key): self.files.create( file_object=uploaded_file, param_name=key, ) def get_post_parameters(self, request, url): """ Produces submission data for POST as (data_dict, files_dict). """ self._data = {} for (key, value) in self.submission_data or {}: if key in self._data: self._data[key].append(value) else: self._data[key] = [ value ] self._files = {} for file in self.files.all().order_by("id"): # Requests supports only one file per name in a multipart post. self._files[file.param_name] = ( file.filename, open(file.file_object.path, "rb") ) students = list(self.submitters.all()) if self.is_submitter(request.user): user = request.user else: user = students[0].user if students else None self.exercise.as_leaf_class().modify_post_parameters( self._data, self._files, user, students, request, url) return (self._data, self._files) def clean_post_parameters(self): for key in self._files.keys(): self._files[key][1].close() del self._files del self._data def set_points(self, points, max_points, no_penalties=False): """ Sets the points and maximum points for this submissions. If the given maximum points are different than the ones for the exercise this submission is for, the points will be scaled. The method also checks if the submission is late and if it is, by default applies the late_submission_penalty set for the exercise.course_module. If no_penalties is True, the penalty is not applied. """ exercise = self.exercise # Evade bad max points in remote service. if max_points == 0 and points > 0: max_points = exercise.max_points # The given points must be between zero and max points assert 0 <= points <= max_points # If service max points is zero, then exercise max points must be zero # too because otherwise adjusted_grade would be ambiguous. # Disabled: Teacher is always responsible the exercise can be passed. #assert not (max_points == 0 and self.exercise.max_points != 0) self.service_points = points self.service_max_points = max_points self.late_penalty_applied = None # Scale the given points to the maximum points for the exercise if max_points > 0: adjusted_grade = (1.0 * exercise.max_points * points / max_points) else: adjusted_grade = 0.0 if not no_penalties: timing,_ = exercise.get_timing(self.submitters.all(), self.submission_time) if timing in (exercise.TIMING.LATE, exercise.TIMING.CLOSED_AFTER): self.late_penalty_applied = ( exercise.course_module.late_submission_penalty if exercise.course_module.late_submissions_allowed else 0 ) adjusted_grade -= (adjusted_grade * self.late_penalty_applied) elif timing == exercise.TIMING.UNOFFICIAL: self.status = self.STATUS.UNOFFICIAL if self.exercise.no_submissions_left(self.submitters.all()): self.status = self.STATUS.UNOFFICIAL self.grade = round(adjusted_grade) # Finally check that the grade is in bounds after all the math. assert 0 <= self.grade <= self.exercise.max_points def scale_grade_to(self, percentage): percentage = float(percentage)/100 self.grade = round(max(self.grade*percentage,0)) self.grade = min(self.grade,self.exercise.max_points) def set_waiting(self): self.status = self.STATUS.WAITING def set_ready(self): self.grading_time = timezone.now() if self.status != self.STATUS.UNOFFICIAL: self.status = self.STATUS.READY # Fire set hooks. for hook in self.exercise.course_module.course_instance \ .course_hooks.filter(hook_type="post-grading"): hook.trigger({ "submission_id": self.id, "exercise_id": self.exercise.id, "course_id": self.exercise.course_module.course_instance.id, "site": settings.BASE_URL, }) def set_rejected(self): self.status = self.STATUS.REJECTED def set_error(self): self.status = self.STATUS.ERROR @property def is_graded(self): return self.status in (self.STATUS.READY, self.STATUS.UNOFFICIAL) @property def lang(self): try: return self.meta_data.get('lang', None) except AttributeError: # Handle cases where database includes null or non dictionary json return None ABSOLUTE_URL_NAME = "submission" def get_url_kwargs(self): return dict(submission_id=self.id, **self.exercise.get_url_kwargs()) def get_inspect_url(self): return self.get_url("submission-inspect")
class LearningObject(UrlMixin, ModelWithInheritance): """ All learning objects inherit this model. """ STATUS = Enum([ ('READY', 'ready', _("Ready")), ('UNLISTED', 'unlisted', _("Unlisted in table of contents")), ('ENROLLMENT', 'enrollment', _("Enrollment questions")), ('ENROLLMENT_EXTERNAL', 'enrollment_ext', _("Enrollment questions for external students")), ('HIDDEN', 'hidden', _("Hidden from non course staff")), ('MAINTENANCE', 'maintenance', _("Maintenance")), ]) AUDIENCE = Enum([ ('COURSE_AUDIENCE', 0, _('Course audience')), ('INTERNAL_USERS', 1, _('Only internal users')), ('EXTERNAL_USERS', 2, _('Only external users')), ('REGISTERED_USERS', 3, _('Only registered users')), ]) status = models.CharField(max_length=32, choices=STATUS.choices, default=STATUS.READY) audience = models.IntegerField(choices=AUDIENCE.choices, default=AUDIENCE.COURSE_AUDIENCE) category = models.ForeignKey(LearningObjectCategory, on_delete=models.CASCADE, related_name="learning_objects") course_module = models.ForeignKey(CourseModule, on_delete=models.CASCADE, related_name="learning_objects") parent = models.ForeignKey('self', on_delete=models.SET_NULL, blank=True, null=True, related_name='children') order = models.IntegerField(default=1) url = models.CharField(max_length=512, validators=[generate_url_key_validator()], help_text=_("Input an URL identifier for this object.")) name = models.CharField(max_length=255) description = models.TextField(blank=True, help_text=_("Internal description is not presented on site.")) use_wide_column = models.BooleanField(default=False, help_text=_("Remove the third info column for more space.")) service_url = models.CharField(max_length=4096, blank=True) exercise_info = JSONField(blank=True) model_answers = models.TextField(blank=True, help_text=_("List model answer files as protected URL addresses.")) templates = models.TextField(blank=True, help_text=_("List template files as protected URL addresses.")) # Keep this to support ExerciseWithAttachment # Maybe this should inject extra content to any exercise content = models.TextField(blank=True) objects = LearningObjectManager() class Meta: app_label = "exercise" ordering = ['course_module', 'order', 'id'] unique_together = ['course_module', 'parent', 'url'] def clean(self): """ Validates the model before saving (standard method used in Django admin). """ super().clean() errors = {} RESERVED = ("submissions", "plain", "info") if self.url in RESERVED: errors['url'] = _("Taken words include: {}").format(", ".join(RESERVED)) if self.course_module.course_instance != self.category.course_instance: errors['category'] = _('Course_module and category must belong to the same course instance.') if self.parent: if self.parent.course_module != self.course_module: errors['parent'] = _('Cannot select parent from another course module.') if self.parent.id == self.id: errors['parent'] = _('Cannot select self as a parent.') if errors: raise ValidationError(errors) def save(self, *args, **kwargs): super().save(*args, **kwargs) # Trigger LearningObject post save signal for extending classes. cls = self.__class__ while cls.__bases__: cls = cls.__bases__[0] if cls.__name__ == 'LearningObject': signals.post_save.send(sender=cls, instance=self) def delete(self, *args, **kwargs): super().delete(*args, **kwargs) # Trigger LearningObject post delete signal for extending classes. cls = self.__class__ while cls.__bases__: cls = cls.__bases__[0] if cls.__name__ == 'LearningObject': signals.post_delete.send(sender=cls, instance=self) def __str__(self): if self.order >= 0: if self.course_instance.content_numbering == CourseInstance.CONTENT_NUMBERING.ARABIC: number = self.number() if self.course_instance.module_numbering in ( CourseInstance.CONTENT_NUMBERING.ARABIC, CourseInstance.CONTENT_NUMBERING.HIDDEN, ): return "{:d}.{} {}".format(self.course_module.order, number, self.name) return "{} {}".format(number, self.name) elif self.course_instance.content_numbering == CourseInstance.CONTENT_NUMBERING.ROMAN: return "{} {}".format(roman_numeral(self.order), self.name) return self.name def number(self): return ".".join([str(o.order) for o in self.parent_list()]) def parent_list(self): if not hasattr(self, '_parents'): def recursion(obj, parents): if not obj is None: return recursion(obj.parent, [obj] + parents) return parents self._parents = recursion(self.parent, [self]) return self._parents @property def course_instance(self): return self.course_module.course_instance @property def is_submittable(self): return False def is_empty(self): return not self.service_url and self.as_leaf_class()._is_empty() def _is_empty(self): return True def is_open(self, when=None): return self.course_module.exercises_open(when=when) def is_closed(self, when=None): return self.course_module.is_closed(when=when) @property def can_show_model_solutions(self): """Can model solutions be shown to students? This method checks only the module deadline and ignores personal deadline extensions. """ return self.is_closed() and not self.course_instance.is_on_lifesupport() and not self.course_instance.is_archived() def can_show_model_solutions_to_student(self, student): """Can model solutions be shown to the given student (User)? This method checks personal deadline extensions in addition to the common module deadline. """ # The old version of this method was defined in this LearningObject class # even though only exercises could be submitted to and have model solutions. # Class BaseExercise overrides this method since deadline deviations are # defined only for them, not learning objects. return student.is_authenticated and self.can_show_model_solutions def get_path(self): return "/".join([o.url for o in self.parent_list()]) ABSOLUTE_URL_NAME = "exercise" def get_url_kwargs(self): return dict(exercise_path=self.get_path(), **self.course_module.get_url_kwargs()) def get_display_url(self): if self.status == self.STATUS.UNLISTED and self.parent: return "{}#chapter-exercise-{:d}".format( self.parent_list()[-2].get_absolute_url(), self.order ) return self.get_absolute_url() def get_submission_list_url(self): return self.get_url("submission-list") def load(self, request, students, url_name="exercise"): """ Loads the learning object page. """ page = ExercisePage(self) if not self.service_url: return page language = get_language() cache = ExerciseCache(self, language, request, students, url_name) page.head = cache.head() page.content = cache.content() page.is_loaded = True return page def load_page(self, language, request, students, url_name, last_modified=None): return load_exercise_page( request, self.get_load_url(language, request, students, url_name), last_modified, self ) def get_service_url(self, language): return pick_localized(self.service_url, language) def get_load_url(self, language, request, students, url_name="exercise"): return update_url_params(self.get_service_url(language), { 'lang': language, }) def get_models(self): entries = pick_localized(self.model_answers, get_language()) return [(url,url.split('/')[-1]) for url in entries.split()] def get_templates(self): entries = pick_localized(self.templates, get_language()) return [(url,url.split('/')[-1]) for url in entries.split()] def get_form_spec_keys(self, include_static_fields=False): """Return the keys of the form fields of this exercise. This is based on the form_spec structure of the exercise_info, which is saved in the course JSON import. """ form_spec = ( self.exercise_info.get('form_spec', []) if isinstance(self.exercise_info, dict) else [] ) keys = set() for item in form_spec: key = item.get('key') typ = item.get('type') if not include_static_fields and typ == 'static': continue if key: # avoid empty or missing values keys.add(key) return keys
class CourseDiplomaDesign(models.Model): USERGROUP = CourseInstance.ENROLLMENT_AUDIENCE course = models.OneToOneField( CourseInstance, verbose_name=_('LABEL_COURSE'), on_delete=models.SET_NULL, null=True, ) availability = models.IntegerField( verbose_name=_('LABEL_AVAILABILTY'), choices=USERGROUP.choices, default=USERGROUP.EXTERNAL_USERS, ) logo = models.ImageField( verbose_name=_('LABEL_LOGO'), blank=True, null=True, upload_to=build_upload_dir, ) title = models.TextField( verbose_name=_('LABEL_TITLE'), blank=True, ) body = models.TextField( verbose_name=_('LABEL_BODY'), blank=True, ) date = models.CharField( verbose_name=_('LABEL_DATE'), max_length=256, ) signature_name = models.CharField( verbose_name=_('LABEL_SIGNATURE_NAME'), max_length=256, blank=True, ) signature_title = models.CharField( verbose_name=_('LABEL_SIGNATURE_TITLE'), max_length=256, blank=True, ) small_print = models.TextField( verbose_name=_('LABEL_SMALL_PRINT'), blank=True, ) point_limits = JSONField( verbose_name=_('LABEL_POINT_LIMITS'), blank=True, help_text=_('DIPLOMA_POINT_LIMITS_HELPTEXT'), ) pad_points = models.BooleanField( verbose_name=_('LABEL_PAD_POINTS'), default=False, help_text=_('DIPLOMA_PAD_POINTS_HELPTEXT'), ) exercises_to_pass = models.ManyToManyField( BaseExercise, verbose_name=_('LABEL_EXERCISES_TO_PASS'), blank=True, ) modules_to_pass = models.ManyToManyField( CourseModule, verbose_name=_('LABEL_MODULES_TO_PASS'), blank=True, ) class Meta: verbose_name = _('MODEL_NAME_COURSE_DIPLOMA_DESIGN') verbose_name_plural = _('MODEL_NAME_COURSE_DIPLOMA_DESIGN_PLURAL') def __str__(self): return "CourseDiplomaDesign {} for {}".format(self.pk, str(self.course))
class LearningObject(UrlMixin, ModelWithInheritance): """ All learning objects inherit this model. """ STATUS = Enum([ ('READY', 'ready', _("Ready")), ('UNLISTED', 'unlisted', _("Unlisted in table of contents")), ('ENROLLMENT', 'enrollment', _("Enrollment questions")), ('ENROLLMENT_EXTERNAL', 'enrollment_ext', _("Enrollment questions for external students")), ('HIDDEN', 'hidden', _("Hidden from non course staff")), ('MAINTENANCE', 'maintenance', _("Maintenance")), ]) AUDIENCE = Enum([ ('COURSE_AUDIENCE', 0, _('Course audience')), ('INTERNAL_USERS', 1, _('Only internal users')), ('EXTERNAL_USERS', 2, _('Only external users')), ('REGISTERED_USERS', 3, _('Only registered users')), ]) status = models.CharField(max_length=32, choices=STATUS.choices, default=STATUS.READY) audience = models.IntegerField(choices=AUDIENCE.choices, default=AUDIENCE.COURSE_AUDIENCE) category = models.ForeignKey(LearningObjectCategory, related_name="learning_objects") course_module = models.ForeignKey(CourseModule, related_name="learning_objects") parent = models.ForeignKey('self', on_delete=models.SET_NULL, blank=True, null=True, related_name='children') order = models.IntegerField(default=1) url = models.CharField( max_length=255, validators=[RegexValidator(regex="^[\w\-\.]*$")], help_text=_("Input an URL identifier for this object.")) name = models.CharField(max_length=255) description = models.TextField( blank=True, help_text=_("Internal description is not presented on site.")) use_wide_column = models.BooleanField( default=False, help_text=_("Remove the third info column for more space.")) service_url = models.URLField(blank=True) exercise_info = JSONField(blank=True) model_answers = models.TextField( blank=True, help_text=_("List model answer files as protected URL addresses.")) # Keep this to support ExerciseWithAttachment # Maybe this should inject extra content to any exercise content = models.TextField(blank=True) objects = LearningObjectManager() class Meta: app_label = "exercise" ordering = ['course_module', 'order', 'id'] unique_together = ['course_module', 'parent', 'url'] def clean(self): """ Validates the model before saving (standard method used in Django admin). """ course_instance_error = ValidationError({ 'category': _('Course_module and category must belong to the same course instance.' ) }) try: if (self.course_module.course_instance != self.category.course_instance): raise course_instance_error except (LearningObjectCategory.DoesNotExist, CourseModule.DoesNotExist): raise course_instance_error if self.parent and (self.parent.course_module != self.course_module or self.parent.id == self.id): raise ValidationError({ 'parent': _('Cannot select parent from another course module.') }) RESERVED = ("submissions", "plain", "info") if self.url in RESERVED: raise ValidationError({ 'url': _("Taken words include: {}").format(", ".join(RESERVED)) }) def save(self, *args, **kwargs): super().save(*args, **kwargs) # Trigger LearningObject post save signal for extending classes. cls = self.__class__ while cls.__bases__: cls = cls.__bases__[0] if cls.__name__ == 'LearningObject': signals.post_save.send(sender=cls, instance=self) def __str__(self): if self.order >= 0: if self.course_instance.content_numbering == CourseInstance.CONTENT_NUMBERING.ARABIC: number = self.number() if self.course_instance.module_numbering in ( CourseInstance.CONTENT_NUMBERING.ARABIC, CourseInstance.CONTENT_NUMBERING.HIDDEN, ): return "{:d}.{} {}".format(self.course_module.order, number, self.name) return "{} {}".format(number, self.name) elif self.course_instance.content_numbering == CourseInstance.CONTENT_NUMBERING.ROMAN: return "{} {}".format(roman_numeral(self.order), self.name) return self.name def number(self): return ".".join([str(o.order) for o in self.parent_list()]) def parent_list(self): if not hasattr(self, '_parents'): def recursion(obj, parents): if not obj is None: return recursion(obj.parent, [obj] + parents) return parents self._parents = recursion(self.parent, [self]) return self._parents @property def course_instance(self): return self.course_module.course_instance @property def is_submittable(self): return False def is_empty(self): return not self.service_url and self.as_leaf_class()._is_empty() def _is_empty(self): return True def is_open(self, when=None): return self.course_module.is_open(when=when) def is_after_open(self, when=None): return self.course_module.is_after_open(when=when) def is_closed(self, when=None): return self.course_module.is_closed(when=when) def get_path(self): return "/".join([o.url for o in self.parent_list()]) ABSOLUTE_URL_NAME = "exercise" def get_url_kwargs(self): return dict(exercise_path=self.get_path(), **self.course_module.get_url_kwargs()) def get_display_url(self): if self.status == self.STATUS.UNLISTED and self.parent: return "{}#chapter-exercise-{:d}".format( self.parent_list()[-2].get_absolute_url(), self.order) return self.get_absolute_url() def get_submission_list_url(self): return self.get_url("submission-list") def load(self, request, students, url_name="exercise"): """ Loads the learning object page. """ page = ExercisePage(self) if not self.service_url: return page language = get_language() cache = ExerciseCache(self, language, request, students, url_name) page.head = cache.head() page.content = cache.content() page.is_loaded = True return page def load_page(self, language, request, students, url_name, last_modified=None): return load_exercise_page( request, self.get_load_url(language, request, students, url_name), last_modified, self) def get_load_url(self, language, request, students, url_name="exercise"): return update_url_params(self.service_url, { 'lang': language, }) def get_models(self): return [(url, url.split('/')[-1]) for url in self.model_answers.split()]
class Submission(models.Model): """ A submission to some course exercise from one or more submitters. """ STATUS_INITIALIZED = "initialized" STATUS_WAITING = "waiting" STATUS_READY = "ready" STATUS_ERROR = "error" STATUS_CHOICES = ( (STATUS_INITIALIZED, _("Initialized")), (STATUS_WAITING, _("Waiting")), (STATUS_READY, _("Ready")), (STATUS_ERROR, _("Error")), ) submission_time = models.DateTimeField(auto_now_add=True) hash = models.CharField(max_length=32, default=get_random_string) # Relations exercise = models.ForeignKey(exercise_models.BaseExercise, related_name="submissions") submitters = models.ManyToManyField(UserProfile, related_name="submissions") grader = models.ForeignKey(UserProfile, related_name="graded_submissions", blank=True, null=True) # Grading and feedback feedback = models.TextField(blank=True) assistant_feedback = models.TextField(blank=True) status = models.CharField(max_length=32, choices=STATUS_CHOICES, default=STATUS_INITIALIZED) grade = models.IntegerField(default=0) grading_time = models.DateTimeField(blank=True, null=True) late_penalty_applied = PercentField(blank=True, null=True) # Points received from assessment, before scaled to grade service_points = models.IntegerField(default=0) service_max_points = models.IntegerField(default=0) # Additional data submission_data = JSONField(blank=True) grading_data = JSONField(blank=True) objects = SubmissionManager() class Meta: app_label = 'exercise' ordering = ['-submission_time'] def __str__(self): return str(self.id) def is_submitter(self, user): return user and user.is_authenticated() and \ self.submitters.filter(id=user.userprofile.id).exists() def add_files(self, files): """ Adds the given files to this submission as SubmittedFile objects. @param files: a QueryDict containing files from a POST request """ for key in files: for uploaded_file in files.getlist(key): userfile = SubmittedFile() userfile.file_object = uploaded_file userfile.param_name = key self.files.add(userfile) def get_post_parameters(self, request, url): """ Produces submission data for POST as (data_dict, files_dict). """ self._data = {} for (key, value) in self.submission_data or {}: if key in self._data: self._data[key].append(value) else: self._data[key] = [value] self._files = {} for file in self.files.all().order_by("id"): # Requests supports only one file per name in a multipart post. self._files[file.param_name] = (file.filename, open(file.file_object.path, "rb")) if self.is_submitter(request.user): user = request.user else: user = self.submitters.first().user self.exercise.as_leaf_class().modify_post_parameters( self._data, self._files, user, request.get_host(), url) return (self._data, self._files) def clean_post_parameters(self): for key in self._files.keys(): self._files[key][1].close() del self._files del self._data def set_points(self, points, max_points, no_penalties=False): """ Sets the points and maximum points for this submissions. If the given maximum points are different than the ones for the exercise this submission is for, the points will be scaled. The method also checks if the submission is late and if it is, by default applies the late_submission_penalty set for the exercise.course_module. If no_penalties is True, the penalty is not applied. """ # The given points must be between zero and max points assert 0 <= points <= max_points # If service max points is zero, then exercise max points must be zero # too because otherwise adjusted_grade would be ambiguous. # Disabled: Teacher is always responsible the exercise can be passed. #assert not (max_points == 0 and self.exercise.max_points != 0) self.service_points = points self.service_max_points = max_points # Scale the given points to the maximum points for the exercise if max_points > 0: adjusted_grade = (1.0 * self.exercise.max_points * points / max_points) else: adjusted_grade = 0.0 # Check if this submission was done late. If it was, reduce the points # with late submission penalty. No less than 0 points are given. This # is not done if no_penalties is True. if not no_penalties and self.is_late(): self.late_penalty_applied = \ self.exercise.course_module.late_submission_penalty \ if self.exercise.course_module.late_submissions_allowed else 0 adjusted_grade -= (adjusted_grade * self.late_penalty_applied) else: self.late_penalty_applied = None self.grade = round(adjusted_grade) # Finally check that the grade is in bounds after all the math. assert 0 <= self.grade <= self.exercise.max_points def set_waiting(self): self.status = self.STATUS_WAITING def set_ready(self): self.grading_time = timezone.now() self.status = self.STATUS_READY # Fire set hooks. for hook in self.exercise.course_module.course_instance \ .course_hooks.filter(hook_type="post-grading"): hook.trigger({"submission_id": self.id}) def set_error(self): self.status = self.STATUS_ERROR def is_late(self): if self.submission_time <= self.exercise.course_module.closing_time: return False deviation = self.exercise.one_has_deadline_deviation( self.submitters.all()) if deviation and deviation.without_late_penalty\ and self.submission_time <= deviation.get_new_deadline(): return False return True def is_graded(self): return self.status == self.STATUS_READY def head(self): return self.exercise.content_head def get_url(self, name): exercise = self.exercise instance = exercise.course_instance return reverse(name, kwargs={ "course": instance.course.url, "instance": instance.url, "module": exercise.course_module.url, "exercise_path": exercise.get_path(), "submission_id": self.id, }) def get_absolute_url(self): return self.get_url("submission") def get_inspect_url(self): return self.get_url("submission-inspect")
class SubmissionDraft(models.Model): """ An incomplete submission that is saved automatically before the user submits it. A user can have exactly one draft per exercise instead of multiple. The one draft is continuously updated as the user types. """ timestamp = models.DateTimeField( verbose_name=_('LABEL_TIMESTAMP'), auto_now=True, ) exercise = models.ForeignKey(exercise_models.BaseExercise, verbose_name=_('LABEL_EXERCISE'), on_delete=models.CASCADE, related_name='submission_drafts') submitter = models.ForeignKey(UserProfile, verbose_name=_('LABEL_SUBMITTER'), on_delete=models.CASCADE, related_name='submission_drafts') submission_data = JSONField( verbose_name=_('LABEL_SUBMISSION_DATA'), blank=True, ) # This flag is set to False when the student makes an actual submission. # This way the draft doesn't have to be deleted and recreated every time # the student makes a submission and then starts a new draft. active = models.BooleanField( verbose_name=_('LABEL_ACTIVE'), default=True, ) if TYPE_CHECKING: objects: models.Manager['SubmissionDraft'] id: models.AutoField class Meta: verbose_name = _('MODEL_NAME_SUBMISSION_DRAFT') verbose_name_plural = _('MODEL_NAME_SUBMISSION_DRAFT_PLURAL') app_label = 'exercise' unique_together = ('exercise', 'submitter') def load(self, request: HttpRequest) -> ExercisePage: """ Loads the draft page, i.e. the exercise form with the user's incomplete answers filled in. """ enrollment = self.exercise.course_instance.get_enrollment_for( request.user) if enrollment and enrollment.selected_group: students = list(enrollment.selected_group.members.all()) else: students = [request.user.userprofile] page = self.exercise.as_leaf_class().load( request, students, url_name='exercise', ) if self.submission_data: data = pairs_to_dict(self.submission_data) # Format the timestamp so that it can be used in Javascript's Date constructor timestamp = str(int(self.timestamp.timestamp() * 1000)) page.populate_form(field_values=data, data_values={'draft-timestamp': timestamp}, allow_submit=True) return page
class Submission(UrlMixin, models.Model): """ A submission to some course exercise from one or more submitters. """ STATUS = Enum([ ('INITIALIZED', 'initialized', _('STATUS_INITIALIZED')), ('WAITING', 'waiting', _('STATUS_WAITING')), ('READY', 'ready', _('STATUS_READY')), # graded normally ('ERROR', 'error', _('STATUS_ERROR')), ('REJECTED', 'rejected', _('STATUS_REJECTED')), # missing fields etc ('UNOFFICIAL', 'unofficial', _('STATUS_UNOFFICIAL')), # unofficial: graded after the deadline or after exceeding the submission limit ]) submission_time = models.DateTimeField( verbose_name=_('LABEL_SUBMISSION_TIME'), auto_now_add=True, ) hash = models.CharField( verbose_name=_('LABEL_HASH'), max_length=32, default=get_random_string, ) # Relations exercise = models.ForeignKey(exercise_models.BaseExercise, verbose_name=_('LABEL_EXERCISE'), on_delete=models.CASCADE, related_name="submissions") submitters = models.ManyToManyField(UserProfile, verbose_name=_('LABEL_SUBMITTERS'), related_name="submissions") grader = models.ForeignKey( UserProfile, verbose_name=_('LABEL_GRADER'), on_delete=models.SET_NULL, related_name="graded_submissions", blank=True, null=True, ) # Grading and feedback feedback = models.TextField( verbose_name=_('LABEL_FEEDBACK'), blank=True, ) assistant_feedback = models.TextField( verbose_name=_('LABEL_STAFF_FEEDBACK'), blank=True, ) status = models.CharField( verbose_name=_('LABEL_STATUS'), max_length=32, choices=STATUS.choices, default=STATUS.INITIALIZED, ) grade = models.IntegerField( verbose_name=_('LABEL_GRADE'), default=0, ) grading_time = models.DateTimeField( verbose_name=_('LABEL_GRADING_TIME'), blank=True, null=True, ) late_penalty_applied = PercentField( verbose_name=_('LABEL_LATE_PENALTY_APPLIED'), blank=True, null=True, ) force_exercise_points = models.BooleanField( verbose_name=_('LABEL_FORCE_EXERCISE_POINTS'), default=False, ) # Points received from assessment, before scaled to grade service_points = models.IntegerField( verbose_name=_('LABEL_SERVICE_POINTS'), default=0, ) service_max_points = models.IntegerField( verbose_name=_('LABEL_SERVICE_MAX_POINTS'), default=0, ) # Additional data submission_data = JSONField( verbose_name=_('LABEL_SUBMISSION_DATA'), blank=True, ) grading_data = JSONField( verbose_name=_('LABEL_GRADING_DATA'), blank=True, ) meta_data = JSONField( verbose_name=_('LABEL_META_DATA'), blank=True, ) objects = SubmissionManager() class Meta: verbose_name = _('MODEL_NAME_SUBMISSION') verbose_name_plural = _('MODEL_NAME_SUBMISSION_PLURAL') app_label = 'exercise' ordering = ['-id'] def __str__(self): return str(self.id) def ordinal_number(self): return self.submitters.first().submissions.exclude_errors().filter( exercise=self.exercise, submission_time__lt=self.submission_time).count() + 1 def is_submitter(self, user): return user and user.is_authenticated and \ self.submitters.filter(id=user.userprofile.id).exists() def add_files(self, files): """ Adds the given files to this submission as SubmittedFile objects. @param files: a QueryDict containing files from a POST request """ for key in files: for uploaded_file in files.getlist(key): self.files.create( file_object=uploaded_file, param_name=key, ) def load(self, request: HttpRequest, allow_submit: bool = True) -> ExercisePage: """ Loads the submission page, i.e. the exercise form with the submitted answers filled in. Not the same as the graded form, which is stored in `feedback`. The `allow_submit` argument determines if the submit button will be shown on the page. """ # Load the exercise page and parse its contents submitters = list(self.submitters.all()) page = self.exercise.as_leaf_class().load( request, submitters, url_name='exercise', ordinal=self.ordinal_number(), ) if self.submission_data: data = pairs_to_dict(self.submission_data) page.populate_form(field_values=data, allow_submit=allow_submit) return page def get_post_parameters( self, request: HttpRequest, url: str ) -> Tuple[Dict[str, List[str]], Dict[str, Tuple[str, IO]]]: """ Produces submission data for POST as (data_dict, files_dict). """ if self.submission_data: self._data = pairs_to_dict(self.submission_data) else: self._data = {} self._files = {} for file in self.files.all().order_by("id"): # Requests supports only one file per name in a multipart post. self._files[file.param_name] = (file.filename, open(file.file_object.path, "rb")) students = list(self.submitters.all()) if self.is_submitter(request.user): user = request.user else: user = students[0].user if students else None self.exercise.as_leaf_class().modify_post_parameters( self._data, self._files, user, students, request, url) return (self._data, self._files) def clean_post_parameters(self): for key in self._files.keys(): self._files[key][1].close() del self._files del self._data def set_points(self, points, max_points, no_penalties=False): """ Sets the points and maximum points for this submissions. If the given maximum points are different than the ones for the exercise this submission is for, the points will be scaled. The method also checks if the submission is late and if it is, by default applies the late_submission_penalty set for the exercise.course_module. If no_penalties is True, the penalty is not applied. """ exercise = self.exercise # Evade bad max points in remote service. if max_points == 0 and points > 0: max_points = exercise.max_points # The given points must be between zero and max points assert 0 <= points <= max_points # If service max points is zero, then exercise max points must be zero # too because otherwise adjusted_grade would be ambiguous. # Disabled: Teacher is always responsible the exercise can be passed. #assert not (max_points == 0 and self.exercise.max_points != 0) self.service_points = points self.service_max_points = max_points self.late_penalty_applied = None # Scale the given points to the maximum points for the exercise if max_points > 0: adjusted_grade = (1.0 * exercise.max_points * points / max_points) else: adjusted_grade = 0.0 if not no_penalties: timing, _ = exercise.get_timing(self.submitters.all(), self.submission_time) if timing in (exercise.TIMING.LATE, exercise.TIMING.CLOSED_AFTER): self.late_penalty_applied = ( exercise.course_module.late_submission_penalty if exercise.course_module.late_submissions_allowed else 0) adjusted_grade -= (adjusted_grade * self.late_penalty_applied) elif timing == exercise.TIMING.UNOFFICIAL: self.status = self.STATUS.UNOFFICIAL if self.exercise.no_submissions_left(self.submitters.all()): self.status = self.STATUS.UNOFFICIAL self.grade = round(adjusted_grade) # Finally check that the grade is in bounds after all the math. assert 0 <= self.grade <= self.exercise.max_points def scale_grade_to(self, percentage): percentage = float(percentage) / 100 self.grade = round(max(self.grade * percentage, 0)) self.grade = min(self.grade, self.exercise.max_points) def set_waiting(self): self.status = self.STATUS.WAITING def set_ready(self): self.grading_time = timezone.now() if self.status != self.STATUS.UNOFFICIAL or self.force_exercise_points: self.status = self.STATUS.READY # Fire set hooks. for hook in self.exercise.course_module.course_instance \ .course_hooks.filter(hook_type="post-grading"): hook.trigger({ "submission_id": self.id, "exercise_id": self.exercise.id, "course_id": self.exercise.course_module.course_instance.id, "site": settings.BASE_URL, }) def set_rejected(self): self.status = self.STATUS.REJECTED def set_error(self): self.status = self.STATUS.ERROR @property def is_graded(self): return self.status in (self.STATUS.READY, self.STATUS.UNOFFICIAL) @property def lang(self): try: return self.meta_data.get('lang', None) except AttributeError: # Handle cases where database includes null or non dictionary json return None ABSOLUTE_URL_NAME = "submission" def get_url_kwargs(self): return dict(submission_id=self.id, **self.exercise.get_url_kwargs()) def get_inspect_url(self): return self.get_url("submission-inspect")