Exemple #1
0
class LearningObjectCategory(models.Model):
    """
    Learning objects may be grouped to different categories.
    """
    STATUS = Enum([
        ('READY', 'ready', _("Ready")),
        ('NOTOTAL', 'nototal', _("No total points")),
        ('HIDDEN', 'hidden', _("Hidden")),
    ])
    status = models.CharField(max_length=32,
                              choices=STATUS.choices,
                              default=STATUS.READY)
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    points_to_pass = models.PositiveIntegerField(default=0)
    course_instance = models.ForeignKey(CourseInstance,
                                        related_name="categories")

    #hidden_to = models.ManyToManyField(UserProfile, related_name="hidden_categories",
    #    blank=True, null=True)

    class Meta:
        unique_together = ("name", "course_instance")

    def __str__(self):
        return self.name
Exemple #2
0
class News(models.Model, UrlMixin):
    AUDIENCE = CourseInstance.ENROLLMENT_AUDIENCE
    ALERT = Enum([
        ('NONE', '', _('No alert')),
        ('SUCCESS', 'success', _('Green / Success')),
        ('INFO', 'info', _('Blue / Info')),
        ('WARNING', 'warning', _('Yellow / Warning')),
        ('DANGER', 'danger', _('Red / Danger')),
    ])
    course_instance = models.ForeignKey(CourseInstance,
                                        on_delete=models.CASCADE,
                                        related_name="news")
    audience = models.IntegerField(choices=AUDIENCE.choices,
                                   default=AUDIENCE.ALL_USERS,
                                   verbose_name='目标读者')
    publish = models.DateTimeField(default=timezone.now, verbose_name='发布时间')
    title = models.CharField(max_length=255, verbose_name='标题')
    body = models.TextField(verbose_name='内容')
    pin = models.BooleanField(default=False, verbose_name='置顶')
    alert = models.CharField(max_length=8,
                             blank=True,
                             choices=ALERT.choices,
                             default=ALERT.NONE,
                             verbose_name='类别')

    class Meta:
        ordering = ['course_instance', '-pin', '-publish']
        verbose_name = '公告'
        verbose_name_plural = verbose_name

    def __str__(self):
        return "{} {}".format(str(self.publish), self.title)

    def get_url_kwargs(self):
        return dict(news_id=self.id, **self.course_instance.get_url_kwargs())
Exemple #3
0
class LearningObjectCategory(models.Model):
    """
    Learning objects may be grouped to different categories.
    """
    STATUS = Enum([
        ('READY', 'ready', _("Ready")),
        ('NOTOTAL', 'nototal', _("No total points")),
        ('HIDDEN', 'hidden', _("Hidden")),
    ])
    status = models.CharField(max_length=32,
        choices=STATUS.choices, default=STATUS.READY)
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    points_to_pass = models.PositiveIntegerField(default=0)
    course_instance = models.ForeignKey(CourseInstance, on_delete=models.CASCADE,
        related_name="categories")
    confirm_the_level = models.BooleanField(default=False,
        help_text=_("Once exercise is graded non zero it confirms all the points on the hierarchy level. Implemented as a mandatory feedback feature."))
    accept_unofficial_submits = models.BooleanField(default=False,
        help_text=_("Grade unofficial submissions after deadlines have passed or submission limits have been exceeded. The points are stored but not included in official records."))

    #hidden_to = models.ManyToManyField(UserProfile, related_name="hidden_categories",
    #    blank=True, null=True)

    class Meta:
        unique_together = ("name", "course_instance")

    def __str__(self):
        return self.name
Exemple #4
0
class LTIService(LinkService):
    '''
    A provider of an LTI service.
    '''
    LTI_ACCESS = Enum([
        ('ANON_API_NO', 0, _('Anonymous service, no API access')),
        ('PUBLIC_API_NO', 5, _('Public service, no API access')),
        ('PUBLIC_API_YES', 10, _('Public service, allow API access')),
    ])
    access_settings = models.IntegerField(
        choices=LTI_ACCESS.choices,
        default=LTI_ACCESS.ANON_API_NO,
        help_text=
        _("Select whether to pass pseudonymised user data to the LTI service.<br>Public services can also enable sharing the user's API token and course API URL in the LTI launch request. This grants the LTI tool API access with the user's privileges."
          ))
    consumer_key = models.CharField(
        max_length=128,
        help_text=_("The consumer key provided by the LTI service."))
    consumer_secret = models.CharField(
        max_length=128,
        help_text=_("The consumer secret provided by the LTI service."))

    @property
    def is_anonymous(self):
        return self.access_settings == self.LTI_ACCESS.ANON_API_NO

    @property
    def api_access(self):
        return self.access_settings == self.LTI_ACCESS.PUBLIC_API_YES
Exemple #5
0
class LearningObjectCategory(models.Model):
    """
    Learning objects may be grouped to different categories.
    """
    STATUS = Enum([
        ('READY', 'ready', _('STATUS_READY')),
        ('NOTOTAL', 'nototal', _('STATUS_NO_TOTAL_POINTS')),
        ('HIDDEN', 'hidden', _('STATUS_HIDDEN')),
    ])
    status = models.CharField(
        verbose_name=_('LABEL_STATUS'),
        max_length=32,
        choices=STATUS.choices,
        default=STATUS.READY,
    )
    name = models.CharField(
        verbose_name=_('LABEL_NAME'),
        max_length=255,
    )
    description = models.TextField(
        verbose_name=_('LABEL_DESCRIPTION'),
        blank=True,
    )
    points_to_pass = models.PositiveIntegerField(
        verbose_name=_('LABEL_POINTS_TO_PASS'),
        default=0,
    )
    course_instance = models.ForeignKey(
        CourseInstance,
        verbose_name=_('LABEL_COURSE_INSTANCE'),
        on_delete=models.CASCADE,
        related_name="categories",
    )
    confirm_the_level = models.BooleanField(
        verbose_name=_('LABEL_CONFIRM_THE_LEVEL'),
        default=False,
        help_text=_(
            'LEARNING_OBJECT_CATEGORY_LEVEL_CONFIRMATION_EXERCISE_HELPTEXT'),
    )
    accept_unofficial_submits = models.BooleanField(
        verbose_name=_('LABEL_ACCEPT_UNOFFICIAL_SUBMITS'),
        default=False,
        help_text=_(
            'LEARNING_OBJECT_CATEGORY_ACCEPT_UNOFFICIAL_SUBMISSIONS_HELPTEXT'),
    )

    #hidden_to = models.ManyToManyField(UserProfile, related_name="hidden_categories",
    #    blank=True, null=True)

    class Meta:
        verbose_name = _('MODEL_NAME_LEARNING_OBJECT_CATEGORY')
        verbose_name_plural = _('MODEL_NAME_LEARNING_OBJECT_CATEGORY_PLURAL')
        unique_together = ("name", "course_instance")

    def __str__(self):
        return self.name
Exemple #6
0
class LTIService(LinkService):
    '''
    A provider of an LTI service.
    '''
    LTI_ACCESS = Enum([
        ('ANON_API_NO', 0, _('Anonymous service, no API access')),
        ('PUBLIC_API_NO', 5, _('Public service, no API access')),
        ('PUBLIC_API_YES', 10, _('Public service, allow API access')),
    ])
    access_settings = models.IntegerField(
        choices=LTI_ACCESS.choices,
        default=LTI_ACCESS.ANON_API_NO,
        help_text=_("Select whether to pass pseudonymised user data to the LTI service.<br>Public services can also enable sharing the user's API token and course API URL in the LTI launch request. This grants the LTI tool API access with the user's privileges.")
    )
    consumer_key = models.CharField(
        max_length=128,
        help_text=_("The consumer key provided by the LTI service.")
    )
    consumer_secret = models.CharField(
        max_length=128,
        help_text=_("The consumer secret provided by the LTI service.")
    )

    def __str__(self):
        out = "(LTI) {}: {}".format(self.menu_label, self.url)
        if not self.enabled:
            return "[Disabled] " + out
        return out

    class Meta:
        verbose_name = '学习工具互动操作服务'
        verbose_name_plural = verbose_name

    @property
    def method(self):
        return 'POST'

    @property
    def sends_user_info(self):
        return True

    @property
    def is_anonymous(self):
        return self.access_settings == self.LTI_ACCESS.ANON_API_NO

    @property
    def api_access(self):
        return self.access_settings == self.LTI_ACCESS.PUBLIC_API_YES

    def get_url(self, replace=None, kwargs={}):
        return reverse('lti-login', kwargs=kwargs)
Exemple #7
0
class LTIService(LinkService):
    '''
    A provider of an LTI service.
    '''
    LTI_ACCESS = Enum([
        ('ANON_API_NO', 0, _('LTI_SERVICE_ANONYMOUS_NO_API')),
        ('PUBLIC_API_NO', 5, _('LTI_SERVICE_PUBLIC_NO_API')),
        ('PUBLIC_API_YES', 10, _('LTI_SERVICE_PUBLIC_YES_API')),
    ])
    access_settings = models.IntegerField(
        verbose_name=_('LABEL_ACCESS_SETTINGS'),
        choices=LTI_ACCESS.choices,
        default=LTI_ACCESS.ANON_API_NO,
        help_text=_('LTI_SERVICE_ACCESS_SETTINGS_HELPTEXT'))
    consumer_key = models.CharField(
        verbose_name=_('LABEL_CONSUMER_KEY'),
        max_length=128,
        help_text=_('LTI_SERVICE_CONSUMER_KEY_HELPTEXT'))
    consumer_secret = models.CharField(
        verbose_name=_('LABEL_CONSUMER_SECRET'),
        max_length=128,
        help_text=_('LTI_SERVICE_CONSUMER_SECRET_HELPTEXT'))

    class Meta:
        verbose_name = _('MODEL_NAME_LTI_SERVICE')
        verbose_name_plural = _('MODEL_NAME_LTI_SERVICE_PLURAL')

    def __str__(self):
        out = "(LTI) {}: {}".format(self.menu_label, self.url)
        if not self.enabled:
            return "[Disabled] " + out
        return out

    @property
    def method(self):
        return 'POST'

    @property
    def sends_user_info(self):
        return True

    @property
    def is_anonymous(self):
        return self.access_settings == self.LTI_ACCESS.ANON_API_NO

    @property
    def api_access(self):
        return self.access_settings == self.LTI_ACCESS.PUBLIC_API_YES

    def get_url(self, replace=None, kwargs={}):
        return reverse('lti-login', kwargs=kwargs)
Exemple #8
0
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
Exemple #9
0
class BaseExercise(LearningObject):
    """
    The common parts for all exercises.
    """
    # Timing enumeration is only used as a return value.
    TIMING = Enum([
        ('CLOSED_BEFORE', 0, "Submissions are not yet accepted"),
        ('OPEN', 1, "Normal submissions are accepted"),
        ('LATE', 2, "Late submissions are accepted"),
        ('UNOFFICIAL', 3, "Only unofficial submissions are accepted"),
        ('CLOSED_AFTER', 4, "Submissions are not anymore accepted"),
        ('ARCHIVED', 5, "Course is archived and so are exercises"),
    ])

    SUBMIT_STATUS = Enum([
        ('ALLOWED', 1, ''),
        ('CANNOT_ENROLL', 2, 'You cannot enroll in the course.'),
        ('NOT_ENROLLED', 3, 'You must enroll at course home.'),
        ('INVALID_GROUP', 4, 'The selected group is not acceptable.'),
        ('AMOUNT_EXCEEDED', 5, 'You have used the allowed amount of submissions.'),
        ('INVALID', 999, 'You cannot submit for an unspecified reason.'),
    ])

    allow_assistant_viewing = models.BooleanField(default=True)
    allow_assistant_grading = models.BooleanField(default=False)
    min_group_size = models.PositiveIntegerField(default=1)
    max_group_size = models.PositiveIntegerField(default=1)
    max_submissions = models.PositiveIntegerField(default=10)
    max_points = models.PositiveIntegerField(default=100)
    points_to_pass = models.PositiveIntegerField(default=40)
    difficulty = models.CharField(max_length=32, blank=True)

    objects = BaseExerciseManager()

    class Meta:
        app_label = 'exercise'

    def clean(self):
        """
        Validates the model before saving (standard method used in Django admin).
        """
        super().clean()
        errors = {}
        if self.points_to_pass > self.max_points:
            errors['points_to_pass'] = _("Points to pass cannot be greater than max_points.")
        if self.min_group_size > self.max_group_size:
            errors['min_group_size'] = _("Minimum group size cannot exceed maximum size.")
        if errors:
            raise ValidationError(errors)

    @property
    def is_submittable(self):
        return True

    def get_timing(self, students, when):
        module = self.course_module
        # Check the course instance archive time first so that submissions
        # are never accepted after it.
        dl = module.course_instance.archive_start
        if module.course_instance.is_archived(when=when):
            return self.TIMING.ARCHIVED, dl

        if not module.have_exercises_been_opened(when=when):
            return self.TIMING.CLOSED_BEFORE, module.opening_time

        category = self.category
        if module.exercises_open(when=when) or category.confirm_the_level:
            return self.TIMING.OPEN, module.closing_time

        deviation = self.one_has_deadline_deviation(students)
        dl = deviation.get_new_deadline() if deviation else None
        if dl and when <= dl:
            if deviation.without_late_penalty:
                return self.TIMING.OPEN, dl
            return self.TIMING.LATE, dl

        if module.is_late_submission_open(when=when):
            return self.TIMING.LATE, module.late_submission_deadline

        dl = dl or (module.late_submission_deadline
            if module.late_submissions_allowed else module.closing_time)
        if category.accept_unofficial_submits:
            return self.TIMING.UNOFFICIAL, dl

        return self.TIMING.CLOSED_AFTER, dl

    def delta_in_minutes_from_closing_to_date(self, future_date):
        module_close = self.course_module.closing_time
        # module_close is in utc format 2018-04-10 23:59:00+00:00
        # while future_date from the teacher submitted form might
        # be in different formet, eg. 2018-05-15 23:59:00+03:00
        # -> convert future_date to same format as module_close
        string_date = str(future_date)[:16]
        converted = timezone.make_aware(
                datetime.datetime.strptime(string_date, '%Y-%m-%d %H:%M'),
                timezone.get_current_timezone())
        delta = converted - module_close
        return delta.days * 24 * 60 + delta.seconds // 60

    def one_has_access(self, students, when=None):
        """
        Checks if any of the users can submit taking the granted extra time
        in consideration.
        """
        timing,d = self.get_timing(students, when or timezone.now())

        formatted_time = date_format(timezone.localtime(d), "DATETIME_FORMAT")
        if timing == self.TIMING.OPEN:
            return True,[]
        if timing == self.TIMING.LATE:
            # xgettext:no-python-format
            return True,[_("Deadline for the exercise has passed. Late submissions are allowed until {date} but points are only worth {percent:d}% of normal.").format(
                date=formatted_time,
                percent=self.course_module.get_late_submission_point_worth(),
            )]
        if timing == self.TIMING.UNOFFICIAL:
            return True,[_("Deadline for the exercise has passed ({date}). You may still submit to receive feedback, but your current grade will not change.").format(
                date=formatted_time,
            )]
        if timing == self.TIMING.CLOSED_BEFORE:
            return False,[_("The exercise opens {date} for submissions.").format(
                date=formatted_time,
            )]
        if timing == self.TIMING.CLOSED_AFTER:
            return False,[_("Deadline for the exercise has passed ({date}).").format(
                date=formatted_time,
            )]
        if timing == self.TIMING.ARCHIVED:
            return False,[_("This course has been archived ({date}).").format(
                date=formatted_time,
            )]
        return False,["ERROR"]

    def one_has_deadline_deviation(self, students):
        deviation = None
        for profile in students:
            for d in self.deadlineruledeviation_set.filter(submitter=profile):
                if not deviation\
                        or d.get_new_deadline() > deviation.get_new_deadline():
                    deviation = d
        return deviation

    def number_of_submitters(self):
        return self.course_instance.students\
            .filter(submissions__exercise=self).distinct().count()

    def get_submissions_for_student(self, user_profile, exclude_errors=False):
        if exclude_errors:
            submissions = user_profile.submissions.exclude_errors()
        else:
            submissions = user_profile.submissions
        return submissions.filter(exercise=self)

    def max_submissions_for_student(self, user_profile):
        """
        Calculates student specific max_submissions considering the possible
        MaxSubmissionsRuleDeviation for this student.
        """
        deviation = self.maxsubmissionsruledeviation_set \
            .filter(submitter=user_profile).first()
        if deviation:
            return self.max_submissions + deviation.extra_submissions
        return self.max_submissions

    def one_has_submissions(self, students):
        if self.max_submissions == 0:
            return True, []
        submission_count = 0
        for profile in students:
            # The students are in the same group, therefore, each student should
            # have the same submission count. However, max submission deviation
            # may be set for only one group member.
            submission_count = self.get_submissions_for_student(profile, True).count()
            if submission_count < self.max_submissions_for_student(profile):
                return True, []
        max_unofficial_submissions = settings.MAX_UNOFFICIAL_SUBMISSIONS
        if self.category.accept_unofficial_submits and \
                (max_unofficial_submissions == 0 or submission_count < max_unofficial_submissions):
            # Note: time is not checked here, but unofficial submissions are
            # not allowed if the course archive time has passed.
            # The caller must check the time limits too.
            return True, [_('You have used the allowed amount of submissions for this exercise. You may still submit to receive feedback, but your current grade will not change.')]
        return False, [_('You have used the allowed amount of submissions for this exercise.')]

    def no_submissions_left(self, students):
        if self.max_submissions == 0:
            return False
        for profile in students:
            if self.get_submissions_for_student(profile, True).count() \
                    <= self.max_submissions_for_student(profile):
                return False
        return True

    def check_submission_allowed(self, profile, request=None):
        """
        Checks whether the submission to this exercise is allowed for the given
        user and generates a list of warnings.

        @return: (success_flag, warning_message_list)
        """
        success, warnings, students = self._check_submission_allowed(profile, request)
        return success, list(str(w) for w in warnings), students

    def _check_submission_allowed(self, profile, request=None):
        students = [profile]
        warnings = []

        # Let course module settings decide submissionable state.
        #if self.course_instance.ending_time < timezone.now():
        #    warnings.append(_('The course is archived. Exercises are offline.'))
        #    return False, warnings, students

        # Check enrollment requirements.
        enrollment = self.course_instance.get_enrollment_for(profile.user)
        if self.status in (
            LearningObject.STATUS.ENROLLMENT,
            LearningObject.STATUS.ENROLLMENT_EXTERNAL,
        ):
            if not self.course_instance.is_enrollment_open():
                return (self.SUBMIT_STATUS.CANNOT_ENROLL,
                        [_('The enrollment is not open.')],
                        students)
            if not self.course_instance.is_enrollable(profile.user):
                return (self.SUBMIT_STATUS.CANNOT_ENROLL,
                        [_('You cannot enroll in the course.')],
                        students)
        elif not enrollment:
            if self.course_instance.is_course_staff(profile.user):
                return (self.SUBMIT_STATUS.ALLOWED,
                        [_('Staff can submit exercises without enrolling.')],
                        students)
            return (self.SUBMIT_STATUS.NOT_ENROLLED,
                    [_('You must enroll in the course to submit exercises.')],
                    students)

        # Support group id from post or currently selected group.
        group = None
        group_id = request.POST.get("_aplus_group") if request else None
        if not group_id is None:
            try:
                gid = int(group_id)
                if gid > 0:
                    group = profile.groups.filter(
                        course_instance=self.course_instance,
                        id=gid).first()
            except ValueError:
                pass
        elif enrollment and enrollment.selected_group:
            group = enrollment.selected_group

        # Check groups cannot be changed after submitting.
        submission = self.get_submissions_for_student(profile).first()
        if submission:
            if self._detect_group_changes(profile, group, submission):
                msg = _("Group can only change between different exercises.")
                warning = _('You have previously submitted this '
                            'exercise {with_group}. {msg}')
                if submission.submitters.count() == 1:
                    warning = warning.format(with_group=_('alone'), msg=msg)
                else:
                    collaborators = StudentGroup.format_collaborator_names(
                            submission.submitters.all(), profile)
                    with_group = _('with {}').format(collaborators)
                    warning = warning.format(with_group=with_group, msg=msg)
                warnings.append(warning)
                return self.SUBMIT_STATUS.INVALID_GROUP, warnings, students

        elif self._detect_submissions(profile, group):
            warnings.append(_('{collaborators} already submitted to this exercise in a different group.').format(
                collaborators=group.collaborator_names(profile)))
            return self.SUBMIT_STATUS.INVALID_GROUP, warnings, students

        # Get submitters.
        if group:
            students = list(group.members.all())

        # Check group size.
        if not (self.min_group_size <= len(students) <= self.max_group_size):
            if self.max_group_size == self.min_group_size:
                size = "{:d}".format(self.min_group_size)
            else:
                size = "{:d}-{:d}".format(self.min_group_size, self.max_group_size)
            warnings.append(
                _("This exercise must be submitted in groups of {size} students.")
                .format(size=size))
        if self.status in (self.STATUS.ENROLLMENT, self.STATUS.ENROLLMENT_EXTERNAL):
            access_ok, access_warnings = True, []
        else:
            access_ok, access_warnings = self.one_has_access(students)
        is_staff = all(self.course_instance.is_course_staff(p.user) for p in students)
        ok = (access_ok and len(warnings) == 0) or is_staff
        all_warnings = warnings + access_warnings
        if not ok:
            if len(all_warnings) == 0:
                all_warnings.append(_(
                    'Cannot submit exercise due to unknown reason. If you '
                    'think this is an error, please contact course staff.'))
            return self.SUBMIT_STATUS.INVALID, all_warnings, students

        submit_limit_ok, submit_limit_warnings = self.one_has_submissions(students)
        if not submit_limit_ok and not is_staff:
            # access_warnings are not needed here
            return (self.SUBMIT_STATUS.AMOUNT_EXCEEDED,
                    submit_limit_warnings,
                    students)

        return self.SUBMIT_STATUS.ALLOWED, all_warnings + submit_limit_warnings, students

    def _detect_group_changes(self, profile, group, submission):
        submitters = list(submission.submitters.all())
        if group:
            return not group.equals(submitters)
        else:
            return len(submitters) > 1 or submitters[0] != profile

    def _detect_submissions(self, profile, group):
        if group:
            return not all((
                len(self.get_submissions_for_student(p)) == 0
                for p in group.members.all() if p != profile
            ))
        return False

    def get_total_submitter_count(self):
        return UserProfile.objects \
            .filter(submissions__exercise=self) \
            .distinct().count()

    def get_load_url(self, language, request, students, url_name="exercise"):
        if self.id:
            if request.user.is_authenticated:
                user = request.user
                submission_count = self.get_submissions_for_student(
                    user.userprofile, exclude_errors=True
                ).count()
            else:
                user = None
                submission_count = 0
            # Make grader async URL for the currently authenticated user.
            # The async handler will handle group selection at submission time.
            submission_url = update_url_params(
                api_reverse("exercise-grader", kwargs={
                    'exercise_id': self.id
                }),
                get_graderauth_exercise_params(self, user),
            )
            return self._build_service_url(
                language, request, students,
                submission_count + 1, url_name, submission_url
            )
        return super().get_load_url(language, request, students, url_name)

    def grade(self, request, submission, no_penalties=False, url_name="exercise"):
        """
        Loads the exercise feedback page.
        """
        language = get_language()
        submission_url = update_url_params(
            api_reverse("submission-grader", kwargs={
                'submission_id': submission.id
            }),
            get_graderauth_submission_params(submission),
        )
        url = self._build_service_url(
            language, request, submission.submitters.all(),
            submission.ordinal_number(), url_name, submission_url
        )
        try:
            return load_feedback_page(
                request, url, self, submission, no_penalties=no_penalties
            )
        except OSError as error:
            messages.error(request, "Unable to grade the submission. %s: %s" % (
                error.__class__.__name__, error))
            return None

    def modify_post_parameters(self, data, files, user, students, request, url):
        """
        Allows to modify submission POST parameters before they are sent to
        the grader. Extending classes may implement this function.
        """
        pass

    def _build_service_url(self, language, request, students, ordinal_number, url_name, submission_url):
        """
        Generates complete URL with added parameters to the exercise service.
        """
        uid_str = '-'.join(sorted(str(profile.user.id) for profile in students)) if students else ''
        auri = (
            settings.OVERRIDE_SUBMISSION_HOST + submission_url
            if settings.OVERRIDE_SUBMISSION_HOST
            else request.build_absolute_uri(submission_url)
        )
        return update_url_params(self.get_service_url(language), {
            "max_points": self.max_points,
            "max_submissions": self.max_submissions,
            "submission_url": auri,
            "post_url": request.build_absolute_uri(str(self.get_url(url_name))),
            "uid": uid_str,
            "ordinal_number": ordinal_number,
            "lang": language,
        })

    @property
    def can_regrade(self):
        """Can this exercise be regraded in the assessment service, i.e.,
        can previous submissions be uploaded again for grading?"""
        return True

    def can_show_model_solutions_to_student(self, student):
        result = super().can_show_model_solutions_to_student(student)
        if not result:
            return False

        submission = self.get_submissions_for_student(student.userprofile).first()
        if submission:
            # When the exercise uses group submissions, a deadline deviation
            # may be granted to only one group member, but it affects the whole
            # group. Therefore, we must check deadline deviations for all group
            # members. All submissions to one exercise are made with the same group.
            students = list(submission.submitters.all())
        else:
            students = [student.userprofile]

        # Student may not view model solutions if he can still submit and gain
        # points due to a personal deadline extension.
        deviation = self.one_has_deadline_deviation(students)
        if deviation:
            return timezone.now() > deviation.get_new_deadline()
        return True
Exemple #10
0
class LinkService(ModelWithInheritance):
    '''
    A link to an external service.
    '''
    DESTINATION_REGION = Enum([
        ('INTERNAL', 0,
         _('Destination is hosted internally. Link to internal privacy notice.'
           )),
        ('ORGANIZATION', 1,
         _('Destination is hosted in the same organization. Link to a privacy notice.'
           )),
        ('EEA', 3,
         _('Destination is hosted in European Economic Area. Link to a privacy notice.'
           )),
        ('PRIVACYSHIELD', 5,
         _('Destination is hosted out side of European Economic Area, but certified under EU-US Privacy Shield. Link to an extended privacy notice.'
           )),
        ('GLOBAL', 6,
         _('Destination is hosted out side of European Economic Area. Link to an extended privacy notice.'
           )),
    ])
    url = models.CharField(max_length=256, help_text=_("The service URL"))
    destination_region = models.PositiveSmallIntegerField(
        choices=DESTINATION_REGION.choices,
        default=DESTINATION_REGION.GLOBAL,
        help_text=
        _("The geographical area of the destination. Will display correct user notice."
          ),
    )
    privacy_notice_url = models.CharField(
        max_length=512,
        blank=True,
        help_text=
        _("A link to the service privacy notice. This is mandatory for services outside organization!"
          ))
    menu_label = models.CharField(
        max_length=255,
        help_text=_("A default label to show in the course menu."))
    menu_icon_class = models.CharField(
        max_length=32,
        default="globe",
        help_text=
        _("A default menu icon style name, see http://getbootstrap.com/components/#glyphicons-glyphs"
          ))
    enabled = models.BooleanField(
        default=True,
        help_text=_(
            "If not enabled, the service is disabled for all course instances."
        ))

    class Meta:
        ordering = ["menu_label"]

    def __str__(self):
        out = "{}: {}".format(self.menu_label, self.url)
        if not self.enabled:
            return "[Disabled] " + out
        return out

    def clean(self):
        errors = {}
        if self.destination_region > self.DESTINATION_REGION.ORGANIZATION and not self.privacy_notice_url:
            errors['privacy_notice_url'] = ValidationError(
                _('Privacy notice URL is mandatory for services outside organization.'
                  ))
        if errors:
            raise ValidationError(errors)

    @property
    def url_parts(self):
        return urlsplit(self.url)

    @property
    def method(self):
        return 'GET'

    @property
    def sends_user_info(self):
        return False

    def get_url(self, replace=None, kwargs={}):
        '''Return the URL to the launch page of this service.'''
        if self.destination_region > self.DESTINATION_REGION.INTERNAL:
            return reverse('external-service-link', kwargs=kwargs)
        return self.get_final_url(replace)

    def get_final_url(self, replace=None):
        '''Return the launch URL for this service.

        The optional replace parameter may be a relative URL that is joined to
        the URL path of this service. The relative URL must not include a domain.
        '''
        url = self.url
        if replace:
            assert '://' not in replace and not replace.startswith(
                '//'), "Replace can't include domain"
            url = urljoin(url, replace)
        return url
Exemple #11
0
class MenuItem(UrlMixin, models.Model):
    '''
    Attaches link to course menu.
    '''
    ACCESS = Enum([
        ('STUDENT', 0, _("All students, assistants and teachers can access.")),
        ('ASSISTANT', 5, _("Only assistants and teachers can access.")),
        ('TEACHER', 10, _("Only teachers can access.")),
    ])
    course_instance = models.ForeignKey(
        CourseInstance,
        on_delete=models.CASCADE,
        related_name="ext_services",
        help_text=_("A course where the menu item exists."))
    access = models.IntegerField(
        choices=ACCESS.choices,
        default=ACCESS.STUDENT,
    )
    service = models.ForeignKey(
        LinkService,
        on_delete=models.CASCADE,
        blank=True,
        null=True,
        help_text=
        _("An external service to link to. These are configured by administrators."
          ))
    menu_url = models.CharField(
        max_length=256,
        blank=True,
        null=True,
        validators=[validate_no_domain],
        help_text=
        _("""URL that is a) relative to the service URL or b) this course if no service is selected.
Case a: url starting with / overwrites path in service url and extends it otherwise.
case b: url starting with / is absolute within this service and relative to the course path otherwise.
Note that URL entered here can not include scheme or domain."""))
    menu_group_label = models.CharField(
        max_length=255,
        blank=True,
        null=True,
        help_text=_("Places menu item under a group label."))
    menu_label = models.CharField(
        max_length=255,
        blank=True,
        null=True,
        help_text=_("Label for the menu link (else service default)."))
    menu_icon_class = models.CharField(
        max_length=32,
        null=True,
        blank=True,
        help_text=
        _("Menu icon style name (else service default), e.g. star see http://getbootstrap.com/components/#glyphicons-glyphs"
          ))
    menu_weight = models.IntegerField(
        default=0,
        help_text=_("Heavier menu entries are placed after lighter ones."))
    enabled = models.BooleanField(default=True)

    class Meta:
        ordering = ["course_instance", "menu_weight", "menu_label"]

    def __str__(self):
        out = self.label
        if not self.is_enabled:
            return "[Disabled] " + out
        return out

    def clean(self):
        errors = {}
        if not self.service:
            if not self.menu_url:
                errors['menu_url'] = ValidationError(
                    _('Relative URL is required when there is no preconfigured service selected.'
                      ))
            if not self.menu_label:
                errors['menu_label'] = ValidationError(
                    _('Menu label is required when there is no preconfigured service selected.'
                      ))
        if errors:
            raise ValidationError(errors)

    @cached_property
    def is_enabled(self):
        if self.service:
            return self.service.enabled and self.enabled
        return self.enabled

    @cached_property
    def label(self):
        if self.menu_label:
            return self.menu_label
        if self.service:
            return self.service.menu_label
        return ""

    @cached_property
    def icon_class(self):
        if self.menu_icon_class:
            return self.menu_icon_class
        if self.service:
            return self.service.menu_icon_class
        return ""

    @cached_property
    def url(self):
        if self.service:
            kwargs = {
                "course_slug": self.course_instance.course.url,
                "instance_slug": self.course_instance.url,
                "menu_id": self.id,
            }
            return self.service.as_leaf_class().get_url(replace=self.menu_url,
                                                        kwargs=kwargs)
        if '://' in self.menu_url:
            # Deprecated, but DB can have old urls
            return self.menu_url
        return urljoin(self.course_instance.get_absolute_url(), self.menu_url)

    @cached_property
    def final_url(self):
        if self.service:
            return self.service.as_leaf_class().get_final_url(self.menu_url)
        else:
            return urljoin(self.course_instance.get_absolute_url(),
                           self.menu_url)

    def get_url_kwargs(self):
        return dict(menu_id=self.id, **self.course_instance.get_url_kwargs())
Exemple #12
0
class MenuItem(UrlMixin, models.Model):
    '''
    Attaches link to course menu.
    '''
    ACCESS = Enum([
        ('STUDENT', 0, _('MENU_ITEM_ACCESS_ALL')),
        ('ASSISTANT', 5, _('MENU_ITEM_ACCESS_ASSISTANTS_AND_TEACHERS')),
        ('TEACHER', 10, _('MENU_ITEM_ACCESS_TEACHERS')),
    ])
    course_instance = models.ForeignKey(
        CourseInstance,
        verbose_name=_('LABEL_COURSE_INSTANCE'),
        on_delete=models.CASCADE,
        related_name="ext_services",
        help_text=_('MENU_ITEM_COURSE_INSTANCE_HELPTEXT'))
    access = models.IntegerField(
        verbose_name=_('LABEL_ACCESS'),
        choices=ACCESS.choices,
        default=ACCESS.STUDENT,
    )
    service = models.ForeignKey(LinkService,
                                verbose_name=_('LABEL_SERVICE'),
                                on_delete=models.CASCADE,
                                blank=True,
                                null=True,
                                help_text=_('MENU_ITEM_SERVICE_HELPTEXT'))
    menu_url = models.CharField(
        verbose_name=_('LABEL_MENU_URL'),
        max_length=256,
        blank=True,
        null=True,
        help_text=_('MENU_ITEM_MENU_URL_HELPTEXT'
                    ""),
        validators=[validate_no_domain],
    )
    menu_group_label = models.CharField(
        verbose_name=_('LABEL_MENU_GROUP_LABEL'),
        max_length=255,
        blank=True,
        null=True,
        help_text=_('MENU_ITEM_MENU_GROUP_LABEL_HELPTEXT'),
    )
    menu_label = models.CharField(
        verbose_name=_('LABEL_MENU_LABEL'),
        max_length=255,
        blank=True,
        null=True,
        help_text=_('MENU_ITEM_MENU_LINK_LABEL_HELPTEXT'),
    )
    menu_icon_class = models.CharField(
        verbose_name=_('LABEL_MENU_ICON_CLASS'),
        max_length=32,
        null=True,
        blank=True,
        help_text=_('MENU_ITEM_MENU_ICON_CLASS_HELPTEXT'),
    )
    menu_weight = models.IntegerField(
        verbose_name=_('LABEL_MENU_WEIGHT'),
        default=0,
        help_text=_('MENU_ITEM_MENU_WEIGHT_HELPTEXT'),
    )
    enabled = models.BooleanField(
        verbose_name=_('LABEL_ENABLED'),
        default=True,
    )

    class Meta:
        verbose_name = _('MODEL_NAME_MENU_ITEM')
        verbose_name_plural = _('MODEL_NAME_MENU_ITEM_PLURAL')
        ordering = ["course_instance", "menu_weight", "menu_label"]

    def __str__(self):
        out = self.label
        if not self.is_enabled:
            return "[Disabled] " + out
        return out

    def clean(self):
        errors = {}
        if not self.service:
            if not self.menu_url:
                errors['menu_url'] = ValidationError(
                    _('MENU_ITEM_ERROR_MENU_URL_REQUIRED_WHEN_NO_PRECONFIGURED_SERVICE_SELECTED'
                      ))
            if not self.menu_label:
                errors['menu_label'] = ValidationError(
                    _('MENU_ITEM_ERROR_MENU_LABEL_REQUIRED_WHEN_NO_PRECONFIGURED_SERVICE_SELECTED'
                      ))
        if errors:
            raise ValidationError(errors)

    @cached_property
    def is_enabled(self):
        if self.service:
            return self.service.enabled and self.enabled
        return self.enabled

    @cached_property
    def label(self):
        if self.menu_label:
            return self.menu_label
        if self.service:
            return self.service.menu_label
        return ""

    @cached_property
    def icon_class(self):
        if self.menu_icon_class:
            return self.menu_icon_class
        if self.service:
            return self.service.menu_icon_class
        return ""

    @cached_property
    def url(self):
        if self.service:
            kwargs = {
                "course_slug": self.course_instance.course.url,
                "instance_slug": self.course_instance.url,
                "menu_id": self.id,
            }
            return self.service.as_leaf_class().get_url(replace=self.menu_url,
                                                        kwargs=kwargs)
        if '://' in self.menu_url:
            # Deprecated, but DB can have old urls
            return self.menu_url
        return urljoin(self.course_instance.get_absolute_url(), self.menu_url)

    @cached_property
    def final_url(self):
        if self.service:
            return self.service.as_leaf_class().get_final_url(self.menu_url)
        else:
            return urljoin(self.course_instance.get_absolute_url(),
                           self.menu_url)

    def get_url_kwargs(self):
        return dict(menu_id=self.id, **self.course_instance.get_url_kwargs())
Exemple #13
0
class LinkService(ModelWithInheritance):
    '''
    A link to an external service.
    '''
    DESTINATION_REGION = Enum([
        ('INTERNAL', 0, _('DESTINATION_INTERNAL_PRIVACY_NOTICE')),
        ('ORGANIZATION', 1, _('DESTINATION_ORGANIZATION_PRIVACY_NOTICE')),
        ('EEA', 3, _('DESTINATION_EEA_PRIVACY_NOTICE')),
        ('PRIVACYSHIELD', 5, _('DESTINATION_PRIVACYSHIELD_PRIVACY_NOTICE')),
        ('GLOBAL', 6, _('DESTINATION_GLOBAL_PRIVACY_NOTICE')),
    ])
    url = models.CharField(verbose_name=_('LABEL_URL'),
                           max_length=256,
                           help_text=_('SERVICE_URL'))
    destination_region = models.PositiveSmallIntegerField(
        verbose_name=_('LABEL_DESTINATION_REGION'),
        choices=DESTINATION_REGION.choices,
        default=DESTINATION_REGION.GLOBAL,
        help_text=_('SERVICE_DESTINATION_REGION_HELPTEXT'),
    )
    privacy_notice_url = models.CharField(
        verbose_name=_('LABEL_PRIVACY_NOTICE_URL'),
        max_length=512,
        blank=True,
        help_text=_('SERVICE_PRIVACY_NOTICE_URL_HELPTEXT'))
    menu_label = models.CharField(verbose_name=_('LABEL_MENU_LABEL'),
                                  max_length=255,
                                  help_text=_('SERVICE_MENU_LABEL_HELPTEXT'))
    menu_icon_class = models.CharField(
        verbose_name=_('LABEL_MENU_ICON_CLASS'),
        max_length=32,
        default="globe",
        help_text=_('SERVICE_MENU_ICON_HELPTEXT'))
    enabled = models.BooleanField(verbose_name=_('LABEL_ENABLED'),
                                  default=True,
                                  help_text=_('SERVICE_ENABLED_HELPTEXT'))

    class Meta:
        verbose_name = _('MODEL_NAME_LINK_SERVICE')
        verbose_name_plural = _('MODEL_NAME_LINK_SERVICE_PLURAL')
        ordering = ["menu_label"]

    def __str__(self):
        out = "{}: {}".format(self.menu_label, self.url)
        if not self.enabled:
            return "[Disabled] " + out
        return out

    def clean(self):
        errors = {}
        if self.destination_region > self.DESTINATION_REGION.ORGANIZATION and not self.privacy_notice_url:
            errors['privacy_notice_url'] = ValidationError(
                _('SERVICE_ERROR_PRIVACY_NOTICE_URL_MANDATORY'))
        if errors:
            raise ValidationError(errors)

    @property
    def url_parts(self):
        return urlsplit(self.url)

    @property
    def method(self):
        return 'GET'

    @property
    def sends_user_info(self):
        return False

    def get_url(self, replace=None, kwargs={}):
        '''Return the URL to the launch page of this service.'''
        if self.destination_region > self.DESTINATION_REGION.INTERNAL:
            return reverse('external-service-link', kwargs=kwargs)
        return self.get_final_url(replace)

    def get_final_url(self, replace=None):
        '''Return the launch URL for this service.

        The optional replace parameter may be a relative URL that is joined to
        the URL path of this service. The relative URL must not include a domain.
        '''
        url = self.url
        if replace:
            assert '://' not in replace and not replace.startswith(
                '//'), "Replace can't include domain"
            url = urljoin(url, replace)
        return url
Exemple #14
0
class BaseExercise(LearningObject):
    """
    The common parts for all exercises.
    """
    # Timing enumeration is only used as a return value.
    TIMING = Enum([
        ('CLOSED_BEFORE', 0, "Submissions are not yet accepted"),
        ('OPEN', 1, "Normal submissions are accepted"),
        ('LATE', 2, "Late submissions are accepted"),
        ('UNOFFICIAL', 3, "Only unofficial submissions are accepted"),
        ('CLOSED_AFTER', 4, "Submissions are not anymore accepted"),
        ('ARCHIVED', 5, "Course is archived and so are exercises"),
    ])
    allow_assistant_viewing = models.BooleanField(default=True)
    allow_assistant_grading = models.BooleanField(default=False)
    min_group_size = models.PositiveIntegerField(default=1)
    max_group_size = models.PositiveIntegerField(default=1)
    max_submissions = models.PositiveIntegerField(default=10)
    max_points = models.PositiveIntegerField(default=100)
    points_to_pass = models.PositiveIntegerField(default=40)
    difficulty = models.CharField(max_length=32, blank=True)

    class Meta:
        app_label = 'exercise'

    def clean(self):
        """
        Validates the model before saving (standard method used in Django admin).
        """
        super().clean()
        errors = {}
        if self.points_to_pass > self.max_points:
            errors['points_to_pass'] = _(
                "Points to pass cannot be greater than max_points.")
        if self.min_group_size > self.max_group_size:
            errors['min_group_size'] = _(
                "Minimum group size cannot exceed maximum size.")
        if errors:
            raise ValidationError(errors)

    @property
    def is_submittable(self):
        return True

    def get_timing(self, students, when):
        module = self.course_module
        if not module.is_after_open(when=when):
            return self.TIMING.CLOSED_BEFORE, module.opening_time

        category = self.category
        if module.is_open(when=when) or category.confirm_the_level:
            return self.TIMING.OPEN, module.closing_time

        dl = module.course_instance.archive_time
        if dl and module.course_instance.is_archived(when=when):
            return self.TIMING.ARCHIVED, dl

        deviation = self.one_has_deadline_deviation(students)
        dl = deviation.get_new_deadline() if deviation else None
        if dl and when <= dl:
            if deviation.without_late_penalty:
                return self.TIMING.OPEN, dl
            return self.TIMING.LATE, dl

        if module.is_late_submission_open(when=when):
            return self.TIMING.LATE, module.late_submission_deadline

        dl = dl or (module.late_submission_deadline if
                    module.late_submissions_allowed else module.closing_time)
        if category.accept_unofficial_submits:
            return self.TIMING.UNOFFICIAL, dl

        return self.TIMING.CLOSED_AFTER, dl

    def one_has_access(self, students, when=None):
        """
        Checks if any of the users can submit taking the granted extra time
        in consideration.
        """
        timing, d = self.get_timing(students, when or timezone.now())
        if timing == self.TIMING.OPEN:
            return True, []
        if timing == self.TIMING.LATE:
            # xgettext:no-python-format
            return True, [
                _("Deadline for the exercise has passed. Late submissions are allowed until {date} but points are only worth {percent:d}% of normal."
                  ).format(
                      date=date_format(d),
                      percent=self.course_module.
                      get_late_submission_point_worth(),
                  )
            ]
        if timing == self.TIMING.UNOFFICIAL:
            return True, [
                _("Deadline for the exercise has passed ({date}). You may still submit unofficially to receive feedback."
                  ).format(date=date_format(d))
            ]
        if timing == self.TIMING.CLOSED_BEFORE:
            return False, [
                _("The exercise opens {date} for submissions.").format(
                    date=date_format(d))
            ]
        if timing == self.TIMING.CLOSED_AFTER:
            return False, [
                _("Deadline for the exercise has passed ({date}).").format(
                    date=date_format(d))
            ]
        if timing == self.TIMING.ARCHIVED:
            return False, [
                _("This course has been archived ({date}).").format(
                    date=date_format(d))
            ]
        return False, ["ERROR"]

    def one_has_deadline_deviation(self, students):
        deviation = None
        for profile in students:
            for d in self.deadlineruledeviation_set.filter(submitter=profile):
                if not deviation\
                        or d.get_new_deadline() > deviation.get_new_deadline():
                    deviation = d
        return deviation

    def number_of_submitters(self):
        return self.course_instance.students\
            .filter(submissions__exercise=self).distinct().count()

    def get_submissions_for_student(self, user_profile, exclude_errors=False):
        if exclude_errors:
            submissions = user_profile.submissions.exclude_errors()
        else:
            submissions = user_profile.submissions
        return submissions.filter(exercise=self)

    def max_submissions_for_student(self, user_profile):
        """
        Calculates student specific max_submissions considering the possible
        MaxSubmissionsRuleDeviation for this student.
        """
        deviation = self.maxsubmissionsruledeviation_set \
            .filter(submitter=user_profile).first()
        if deviation:
            return self.max_submissions + deviation.extra_submissions
        return self.max_submissions

    def one_has_submissions(self, students):
        if self.max_submissions == 0:
            return True
        for profile in students:
            if self.get_submissions_for_student(profile, True).count() \
                    < self.max_submissions_for_student(profile):
                return True
        return False

    def no_submissions_left(self, students):
        if self.max_submissions == 0:
            return False
        for profile in students:
            if self.get_submissions_for_student(profile, True).count() \
                    <= self.max_submissions_for_student(profile):
                return False
        return True

    def is_submission_allowed(self, profile, request=None):
        """
        Checks whether the submission to this exercise is allowed for the given
        user and generates a list of warnings.

        @return: (success_flag, warning_message_list)
        """
        success, warnings, students = self._check_submission_allowed(
            profile, request)
        return success, list(str(w) for w in warnings), students

    def _check_submission_allowed(self, profile, request=None):
        students = [profile]
        warnings = []

        # Let course module settings decide submissionable state.
        #if self.course_instance.ending_time < timezone.now():
        #    warnings.append(_('The course is archived. Exercises are offline.'))
        #    return False, warnings, students

        # Check enrollment requirements.
        enrollment = self.course_instance.get_enrollment_for(profile.user)
        if self.status in (
                LearningObject.STATUS.ENROLLMENT,
                LearningObject.STATUS.ENROLLMENT_EXTERNAL,
        ):
            if not self.course_instance.is_enrollable(profile.user):
                return False, [_('You cannot enroll in the course.')], students
        elif not enrollment:
            # TODO Provide button to enroll, should there be option to auto-enroll
            return self.course_instance.is_course_staff(profile.user), [
                _('You must enroll at course home to submit exercises.')
            ], students

        # Support group id from post or currently selected group.
        group = None
        group_id = request.POST.get("_aplus_group") if request else None
        if not group_id is None:
            try:
                gid = int(group_id)
                if gid > 0:
                    group = profile.groups.filter(
                        course_instance=self.course_instance, id=gid).first()
            except ValueError:
                pass
        elif enrollment and enrollment.selected_group:
            group = enrollment.selected_group

        # Check groups cannot be changed after submitting.
        submissions = list(self.get_submissions_for_student(profile))
        if len(submissions) > 0:
            s = submissions[0]
            if self._detect_group_changes(profile, group, s):
                msg = str(
                    _("Group can only change between different exercises."))
                if s.submitters.count() == 1:
                    warnings.append(
                        _("You have previously submitted to this exercise alone."
                          ) + " " + msg)
                else:
                    warnings.append(
                        _("You have previously submitted to this exercise with {collaborators}."
                          ).format(collaborators=StudentGroup.
                                   format_collaborator_names(
                                       s.submitters.all(), profile)) + " " +
                        msg)
                return False, warnings, students
        elif self._detect_submissions(profile, group):
            warnings.append(
                _('{collaborators} already submitted to this exercise in a different group.'
                  ).format(collaborators=group.collaborator_names(profile)))
            return False, warnings, students

        # Get submitters.
        if group:
            students = list(group.members.all())

        # Check group size.
        if not (self.min_group_size <= len(students) <= self.max_group_size):
            if self.max_group_size == self.min_group_size:
                size = "{:d}".format(self.min_group_size)
            else:
                size = "{:d}-{:d}".format(self.min_group_size,
                                          self.max_group_size)
            warnings.append(
                _("This exercise must be submitted in groups of {size} students."
                  ).format(size=size))

        access_ok, access_warnings = self.one_has_access(students)

        if not self.one_has_submissions(students):
            if self.course_module.late_submissions_allowed:
                access_warnings.append(
                    _('You have used the allowed amount of submissions for this exercise. You may still submit unofficially to receive feedback.'
                      ))
            else:
                warnings.append(
                    _('You have used the allowed amount of submissions for this exercise.'
                      ))

        ok = ((access_ok and len(warnings) == 0) or all(
            self.course_instance.is_course_staff(p.user) for p in students))
        return ok, warnings + access_warnings, students

    def _detect_group_changes(self, profile, group, submission):
        submitters = list(submission.submitters.all())
        if group:
            return not group.equals(submitters)
        else:
            return len(submitters) > 1 or submitters[0] != profile

    def _detect_submissions(self, profile, group):
        if group:
            return not all((len(self.get_submissions_for_student(p)) == 0
                            for p in group.members.all() if p != profile))
        return False

    def get_total_submitter_count(self):
        return UserProfile.objects \
            .filter(submissions__exercise=self) \
            .distinct().count()

    def get_load_url(self, language, request, students, url_name="exercise"):
        if self.id:
            if request.user.is_authenticated():
                user = request.user
                submission_count = self.get_submissions_for_student(
                    user.userprofile).count()
            else:
                user = None
                submission_count = 0
            # Make grader async URL for the currently authenticated user.
            # The async handler will handle group selection at submission time.
            submission_url = update_url_params(
                api_reverse("exercise-grader", kwargs={'exercise_id':
                                                       self.id}),
                get_graderauth_exercise_params(self, user),
            )
            return self._build_service_url(language, request, students,
                                           submission_count + 1, url_name,
                                           submission_url)
        return super().get_load_url(language, request, students, url_name)

    def grade(self,
              request,
              submission,
              no_penalties=False,
              url_name="exercise"):
        """
        Loads the exercise feedback page.
        """
        language = get_language()
        submission_url = update_url_params(
            api_reverse("submission-grader",
                        kwargs={'submission_id': submission.id}),
            get_graderauth_submission_params(submission),
        )
        url = self._build_service_url(language, request,
                                      submission.submitters.all(),
                                      submission.ordinal_number(), url_name,
                                      submission_url)
        return load_feedback_page(request,
                                  url,
                                  self,
                                  submission,
                                  no_penalties=no_penalties)

    def modify_post_parameters(self, data, files, user, students, host, url):
        """
        Allows to modify submission POST parameters before they are sent to
        the grader. Extending classes may implement this function.
        """
        pass

    def _build_service_url(self, language, request, students, ordinal_number,
                           url_name, submission_url):
        """
        Generates complete URL with added parameters to the exercise service.
        """
        uid_str = '-'.join(sorted(
            str(profile.user.id) for profile in students)) if students else ''
        auri = (settings.OVERRIDE_SUBMISSION_HOST +
                submission_url if settings.OVERRIDE_SUBMISSION_HOST else
                request.build_absolute_uri(submission_url))
        return update_url_params(
            self.service_url, {
                "max_points": self.max_points,
                "max_submissions": self.max_submissions,
                "submission_url": auri,
                "post_url": request.build_absolute_uri(
                    str(self.get_url(url_name))),
                "uid": uid_str,
                "ordinal_number": ordinal_number,
                "lang": language,
            })
Exemple #15
0
                '{}{}{}',
                msg_without_end_punctuation,
                delim,
                message,
            )


# Access mode
# ===========

# All access levels
ACCESS = Enum(
    ('ANONYMOUS', 0, _('ACCESS_ANYONE')),
    ('ENROLL', 1, None),
    ('STUDENT', 3, _('ACCESS_ANY_STUDENT')),
    ('ENROLLED', 4, _('ACCESS_ENROLLED_STUDENT')),
    ('ASSISTANT', 5, _('ACCESS_COURSE_ASSISTANT')),
    ('GRADING', 6, _('ACCESS_GRADING')),
    ('TEACHER', 10, _('ACCESS_TEACHER')),
    ('SUPERUSER', 100, _('ACCESS_SUPERUSER')),
)


class AccessModePermission(MessageMixin, Permission):
    """
    If view has access_mode that is not anonymous, then require authentication
    """
    message = _('ACCESS_PERMISSION_DENIED_MSG')

    def has_permission(self, request, view):
        access_mode = view.get_access_mode()
Exemple #16
0
class CourseInstance(UrlMixin, models.Model):
    """
    CourseInstance class represent an instance of a course. A single course may have
    several instances either at the same time or during different years. All instances
    have the same teacher, but teaching assistants and students are connected to individual
    instances.
    """
    ENROLLMENT_AUDIENCE = Enum([
        ('INTERNAL_USERS', 1, _('INTERNAL_USERS')),
        ('EXTERNAL_USERS', 2, _('EXTERNAL_USERS')),
        ('ALL_USERS', 3, _('ALL_USERS')),
    ])
    VIEW_ACCESS = Enum([
        ('ENROLLED', 1, _('ENROLLED_STUDENTS')),
        ('ENROLLMENT_AUDIENCE', 2, _('ENROLLMENT_AUDIENCE')),
        ('ALL_REGISTERED', 3, _('ALL_REGISTERED_USERS')),
        ('PUBLIC', 4, _('PUBLIC')),
    ])
    INDEX_TYPE = Enum([
        ('RESULTS', 0, _('USER_RESULTS')),
        ('TOC', 1, _('TABLE_OF_CONTENTS')),
        ('LAST', 2, _('LAST_VISITED_LINK')),
        ('EXPERIMENT', 10, _('EXPERIMENTAL_SETUP')),
    ])
    CONTENT_NUMBERING = Enum([
        ('NONE', 0, _('NUMBERING_NONE')),
        ('ARABIC', 1, _('NUMBERING_ARABIC')),
        ('ROMAN', 2, _('NUMBERING_ROMAN')),
        ('HIDDEN', 3, _('NUMBERING_HIDDEN_ARABIC')),
    ])

    course = models.ForeignKey(
        Course,
        verbose_name=_('LABEL_COURSE'),
        on_delete=models.CASCADE,
        related_name="instances",
    )
    instance_name = models.CharField(
        verbose_name=_('LABEL_INSTANCE_NAME'),
        max_length=255,
    )
    url = models.CharField(
        verbose_name=_('LABEL_URL'),
        max_length=255,
        blank=False,
        help_text=_('COURSE_INSTANCE_URL_IDENTIFIER_HELPTEXT'),
        validators=[generate_url_key_validator()],
    )
    visible_to_students = models.BooleanField(
        verbose_name=_('LABEL_VISIBLE_TO_STUDENTS'),
        default=True,
    )
    enrollment_audience = models.IntegerField(
        verbose_name=_('LABEL_ENROLLMENT_AUDIENCE'),
        choices=ENROLLMENT_AUDIENCE.choices,
        default=ENROLLMENT_AUDIENCE.INTERNAL_USERS,
    )
    view_content_to = models.IntegerField(
        verbose_name=_('LABEL_VIEW_CONTENT_TO'),
        choices=VIEW_ACCESS.choices,
        default=VIEW_ACCESS.ENROLLED,
    )
    starting_time = models.DateTimeField(
        verbose_name=_('LABEL_STARTING_TIME'), )
    ending_time = models.DateTimeField(verbose_name=_('LABEL_ENDING_TIME'), )
    lifesupport_time = models.DateTimeField(
        verbose_name=_('LABEL_LIFESUPPORT_TIME'),
        blank=True,
        null=True,
    )
    archive_time = models.DateTimeField(
        verbose_name=_('LABEL_ARCHIVE_TIME'),
        blank=True,
        null=True,
    )
    enrollment_starting_time = models.DateTimeField(
        verbose_name=_('LABEL_ENROLLMENT_STARTING_TIME'),
        blank=True,
        null=True,
    )
    enrollment_ending_time = models.DateTimeField(
        verbose_name=_('LABEL_ENROLLMENT_ENDING_TIME'),
        blank=True,
        null=True,
    )
    image = models.ImageField(
        verbose_name=_('LABEL_IMAGE'),
        blank=True,
        null=True,
        upload_to=build_upload_dir,
    )
    language = models.CharField(
        verbose_name=_('LABEL_LANGUAGE'),
        max_length=255,
        blank=True,
        default="",
    )
    description = models.TextField(
        verbose_name=_('LABEL_DESCRIPTION'),
        blank=True,
    )
    footer = models.TextField(
        verbose_name=_('LABEL_FOOTER'),
        blank=True,
    )
    index_mode = models.IntegerField(
        verbose_name=_('LABEL_INDEX_MODE'),
        choices=INDEX_TYPE.choices,
        default=INDEX_TYPE.RESULTS,
        help_text=_('COURSE_INSTANCE_INDEX_CONTENT_SELECTION_HELPTEXT'),
    )
    module_numbering = models.IntegerField(
        verbose_name=_('LABEL_MODULE_NUMBERING'),
        choices=CONTENT_NUMBERING.choices,
        default=CONTENT_NUMBERING.ARABIC,
    )
    content_numbering = models.IntegerField(
        verbose_name=_('LABEL_CONTENT_NUMBERING'),
        choices=CONTENT_NUMBERING.choices,
        default=CONTENT_NUMBERING.ARABIC,
    )
    head_urls = models.TextField(
        verbose_name=_('LABEL_HEAD_URLS'),
        blank=True,
        help_text=_(
            'COURSE_INSTANCE_EXTERNAL_CSS_AND_JS_FOR_ALL_PAGES_HELPTEXT'),
    )
    configure_url = models.URLField(blank=True)
    build_log_url = models.URLField(blank=True)
    last_modified = models.DateTimeField(auto_now=True, blank=True, null=True)
    technical_error_emails = models.CharField(
        verbose_name=_('LABEL_TECHNICAL_ERROR_EMAILS'),
        max_length=255,
        blank=True,
        help_text=_(
            'COURSE_INSTANCE_EXERCISE_ERROR_EMAIL_RECIPIENT_OVERRIDE_HELPTEXT'
        ),
    )
    plugins = GenericRelation(BasePlugin,
                              object_id_field="container_pk",
                              content_type_field="container_type")
    tabs = GenericRelation(BaseTab,
                           object_id_field="container_pk",
                           content_type_field="container_type")

    sis_id = models.CharField(
        verbose_name=_('LABEL_SIS_IDENTIFIER'),
        max_length=255,
        blank=True,
        default="",
    )

    sis_enroll = models.BooleanField(
        verbose_name=_('LABEL_SIS_ENROLL'),
        default=False,
        help_text=_('COURSE_INSTANCE_SIS_ENROLL_HELP'))

    # usertags from course.models.UserTag
    # taggings from course.models.UserTagging
    # categories from course.models.LearningObjectCategory
    # course_modules from course.models.CourseModule

    objects = CourseInstanceManager()

    class Meta:
        verbose_name = _('MODEL_NAME_COURSE_INSTANCE')
        verbose_name_plural = _('MODEL_NAME_COURSE_INSTANCE_PLURAL')
        unique_together = ("course", "url")

    def __str__(self):
        return "{}: {}".format(str(self.course), self.instance_name)

    def clean(self):
        super().clean()
        errors = {}
        RESERVED = ("instances", )
        if self.instance_name in RESERVED:
            errors['instance_name'] = format_lazy(
                _('COURSE_INSTANCE_ERROR_INSTANCE_NAME -- {}'),
                self.instance_name)
        if self.url in RESERVED:
            errors['url'] = format_lazy(_('COURSE_INSTANCE_ERROR_URL -- {}'),
                                        self.url)
        if self.ending_time <= self.starting_time:
            errors['ending_time'] = _(
                'COURSE_INSTANCE_ERROR_ENDING_TIME_BEFORE_STARTING')
        if self.lifesupport_time and self.lifesupport_time < self.ending_time:
            errors['lifesupport_time'] = _(
                'COURSE_INSTANCE_ERROR_LIFESUPPORT_TIME_BEFORE_ENDING')
        if (self.archive_time and not self.lifesupport_time) \
                or (self.lifesupport_time and not self.archive_time):
            # Must not set only one of lifesupport and archive time since their
            # default values could change their order. Lifesupport time must not
            # be earlier than archive time.
            errors['archive_time'] = _(
                'COURSE_INSTANCE_ERROR_ARCHIVE_TIME_AND_LIFESUPPORT_ONLY_ONE_SET'
            )
        elif self.archive_time and self.archive_time < self.lifesupport_time:
            errors['archive_time'] = _(
                'COURSE_INSTANCE_ERROR_ARCHIVE_TIME_BEFORE_LIFESUPPORT')
        if self.language.startswith("|"):
            langs = list(filter(
                None,
                self.language.split("|")))  # remove pipes & empty strings
            for lang in langs:
                if not self.is_valid_language(lang):
                    if "language" in errors:
                        errors['language'] += (", " + lang)
                    else:
                        errors['language'] = _(
                            'COURSE_INSTANCE_ERROR_LANGUAGE(S)_MISSING_FROM_SETTINGS'
                        ) + lang
        elif not self.is_valid_language(self.language):
            errors['language'] = _(
                'COURSE_INSTANCE_ERROR_LANGUAGE_MISSING_FROM_SETTINGS')
        if errors:
            raise ValidationError(errors)

    def is_valid_language(self, lang):
        return lang == "" or lang in [key for key, name in settings.LANGUAGES]

    @property
    def languages(self):
        return self.language.strip('|').split('|')

    @property
    def default_language(self):
        language = self.language
        language_code = language.lstrip('|').split('|', 1)[0]
        if language_code:
            return language_code
        return settings.LANGUAGE_CODE.split('-', 1)[0]

    @property
    def students(self):
        return UserProfile.objects.filter(
            enrollment__role=Enrollment.ENROLLMENT_ROLE.STUDENT,
            enrollment__status=Enrollment.ENROLLMENT_STATUS.ACTIVE,
            enrollment__course_instance=self)

    @property
    def all_students(self):
        return UserProfile.objects.filter(
            enrollment__role=Enrollment.ENROLLMENT_ROLE.STUDENT,
            enrollment__course_instance=self).annotate(
                enrollment_status=F('enrollment__status'))

    @property
    def assistants(self):
        return UserProfile.objects.filter(
            enrollment__role=Enrollment.ENROLLMENT_ROLE.ASSISTANT,
            enrollment__status=Enrollment.ENROLLMENT_STATUS.ACTIVE,
            enrollment__course_instance=self)

    @property
    def teachers(self):
        return UserProfile.objects.filter(
            enrollment__role=Enrollment.ENROLLMENT_ROLE.TEACHER,
            enrollment__status=Enrollment.ENROLLMENT_STATUS.ACTIVE,
            enrollment__course_instance=self)

    @property
    def course_staff(self):
        return UserProfile.objects.filter(
            Q(enrollment__role=Enrollment.ENROLLMENT_ROLE.TEACHER)
            | Q(enrollment__role=Enrollment.ENROLLMENT_ROLE.ASSISTANT),
            enrollment__status=Enrollment.ENROLLMENT_STATUS.ACTIVE,
            enrollment__course_instance=self)

    @property
    def course_staff_and_students(self):
        return UserProfile.objects.filter(
            enrollment__status=Enrollment.ENROLLMENT_STATUS.ACTIVE,
            enrollment__course_instance=self)

    def save(self, *args, **kwargs):
        """
        Saves the model.
        """
        super().save(*args, **kwargs)
        if self.image:
            resize_image(self.image.path, (800, 600))

    def is_assistant(self, user):
        return (user and user.is_authenticated and isinstance(user, User)
                and self.assistants.filter(id=user.userprofile.id).exists())

    def is_teacher(self, user):
        return (
            user and user.is_authenticated and
            (user.is_superuser or
             (isinstance(user, User)
              and self.teachers.filter(id=user.userprofile.id).exists()) or
             (isinstance(user, GraderUser) and
              (Permission.WRITE, self.course) in user.permissions.courses)))

    def is_course_staff(self, user):
        return self.is_teacher(user) or self.is_assistant(user)

    def is_student(self, user):
        return (user and user.is_authenticated and isinstance(user, User)
                and self.students.filter(id=user.userprofile.id).exists())

    def is_banned(self, user):
        return (user and user.is_authenticated and isinstance(user, User)
                and self.all_students.filter(
                    enrollment__status=Enrollment.ENROLLMENT_STATUS.BANNED,
                    id=user.userprofile.id,
                ).exists())

    def is_enrollable(self, user):
        if self.is_course_staff(user):
            # Allow course staff to enroll even if the course instance is hidden
            # or the user does not belong to the enrollment audience.
            return True
        enrollment = self.get_enrollment_for(user)
        if enrollment and enrollment.status == Enrollment.ENROLLMENT_STATUS.BANNED:
            return False
        if user and user.is_authenticated and self.visible_to_students:
            if self.enrollment_audience == self.ENROLLMENT_AUDIENCE.INTERNAL_USERS:
                return not user.userprofile.is_external
            if self.enrollment_audience == self.ENROLLMENT_AUDIENCE.EXTERNAL_USERS:
                return user.userprofile.is_external
            return True
        return False

    def enroll_student(self, user, from_sis=False):
        # Return value False indicates whether that the user was already enrolled.
        if user and user.is_authenticated:
            try:
                enrollment = Enrollment.objects.get(
                    course_instance=self,
                    user_profile=user.userprofile,
                )
                if (enrollment.role == Enrollment.ENROLLMENT_ROLE.STUDENT
                        and enrollment.status
                        == Enrollment.ENROLLMENT_STATUS.ACTIVE):
                    if not enrollment.from_sis and from_sis:
                        enrollment.from_sis = from_sis
                        enrollment.save()
                    return False
                enrollment.role = Enrollment.ENROLLMENT_ROLE.STUDENT
                enrollment.status = Enrollment.ENROLLMENT_STATUS.ACTIVE
                enrollment.from_sis = from_sis
                enrollment.save()
                return True
            except Enrollment.DoesNotExist:
                Enrollment.objects.create(
                    course_instance=self,
                    user_profile=user.userprofile,
                    role=Enrollment.ENROLLMENT_ROLE.STUDENT,
                    status=Enrollment.ENROLLMENT_STATUS.ACTIVE,
                    from_sis=from_sis,
                )
                return True
        return False

    def enroll_from_sis(self) -> int:
        """
        Enroll students based on the participants information in Student Info System.
        If student has removed herself in SIS, she will also be marked as removed in A+.

        Returns
        -------
        Number of students enrolled based on this call. -1 if there was problem accessing SIS.
        """
        from .sis import get_sis_configuration, StudentInfoSystem
        from .cache.menu import invalidate_content

        sis: StudentInfoSystem = get_sis_configuration()
        if not sis:
            return -1

        count = 0
        try:
            participants = sis.get_participants(self.sis_id)
        except Exception as e:
            logger.exception(f"Error in getting participants from SIS.")
            return -1

        for i in participants:
            try:
                profile = UserProfile.get_by_student_id(i)
                if self.enroll_student(profile.user, from_sis=True):
                    count = count + 1

            except UserProfile.DoesNotExist:
                # This is a common scenario, if the user has enrolled in SIS, but not
                # yet logged in to A+, then the user profile does not exist yet.
                pass

        # Remove SIS-enrolled students who are not anymore in SIS participants,
        # for example, because they have first enrolled in SIS, but then
        # unenrolled themselves.
        students = self.all_students.filter(enrollment__from_sis=True)
        to_remove = students.exclude(student_id__in=participants)
        qs = Enrollment.objects.filter(user_profile__in=to_remove,
                                       course_instance=self)
        qs.update(status=Enrollment.ENROLLMENT_STATUS.REMOVED)
        for e in qs:
            invalidate_content(Enrollment, e)

        return count

    def set_users_with_role(self, users, role, remove_others_with_role=False):
        # This method is used for adding or replacing (depending on the last
        # parameter) users with a specific role, e.g. teachers and assistants.
        # It is recommended to use the convenience methods (starting with
        # "add"/"clear"/"set") for common use cases.
        for user in users:
            Enrollment.objects.update_or_create(
                course_instance=self,
                user_profile=user,
                defaults={
                    'role': role,
                    'status': Enrollment.ENROLLMENT_STATUS.ACTIVE,
                },
            )

        if remove_others_with_role:
            for enrollment in Enrollment.objects.filter(
                    role=role,
                    status=Enrollment.ENROLLMENT_STATUS.ACTIVE,
                    course_instance=self):
                if enrollment.user_profile not in users:
                    enrollment.status = Enrollment.ENROLLMENT_STATUS.REMOVED
                    enrollment.save()

    def add_assistant(self, user):
        self.set_users_with_role([user], Enrollment.ENROLLMENT_ROLE.ASSISTANT)

    def clear_assistants(self):
        self.set_users_with_role([],
                                 Enrollment.ENROLLMENT_ROLE.ASSISTANT,
                                 remove_others_with_role=True)

    def set_assistants(self, users):
        self.set_users_with_role(users,
                                 Enrollment.ENROLLMENT_ROLE.ASSISTANT,
                                 remove_others_with_role=True)

    def add_teacher(self, user):
        self.set_users_with_role([user], Enrollment.ENROLLMENT_ROLE.TEACHER)

    def clear_teachers(self):
        self.set_users_with_role([],
                                 Enrollment.ENROLLMENT_ROLE.TEACHER,
                                 remove_others_with_role=True)

    def set_teachers(self, users):
        self.set_users_with_role(users,
                                 Enrollment.ENROLLMENT_ROLE.TEACHER,
                                 remove_others_with_role=True)

    def tag_user(self, user, tag):
        UserTagging.objects.create(tag=tag,
                                   user=user.userprofile,
                                   course_instance=self)

    def get_enrollment_for(self, user):
        try:
            return Enrollment.objects.get(course_instance=self,
                                          user_profile=user.userprofile)
        except Enrollment.DoesNotExist:
            return None

    def get_user_tags(self, user):
        return self.taggings.filter(
            user=user.uesrprofile).select_related('tag')

    def get_course_staff_profiles(self):
        return self.course_staff.all()

    def get_student_profiles(self):
        return self.students.all()

    def get_submitted_profiles(self):
        return UserProfile.objects\
            .filter(submissions__exercise__course_module__course_instance=self)\
            .distinct()\
            .exclude(
                Q(enrollment__role=Enrollment.ENROLLMENT_ROLE.TEACHER)
                | Q(enrollment__role=Enrollment.ENROLLMENT_ROLE.ASSISTANT),
                enrollment__status=Enrollment.ENROLLMENT_STATUS.ACTIVE,
                enrollment__course_instance=self)

    def is_open(self, when=None):
        when = when or timezone.now()
        return self.starting_time <= when <= self.ending_time

    def is_past(self, when=None):
        when = when or timezone.now()
        return self.ending_time < when

    def is_on_lifesupport(self, when=None):
        when = when or timezone.now()
        return self.lifesupport_start < when

    def is_archived(self, when=None):
        when = when or timezone.now()
        return self.archive_start < when

    @property
    def archive_start(self):
        if self.archive_time:  # not null
            return self.archive_time
        return self.ending_time + datetime.timedelta(days=365)

    @property
    def lifesupport_start(self):
        if self.lifesupport_time:  # not null
            return self.lifesupport_time
        return self.ending_time + datetime.timedelta(days=365)

    @property
    def enrollment_start(self):
        return self.enrollment_starting_time or self.starting_time

    @property
    def enrollment_end(self):
        return self.enrollment_ending_time or self.ending_time

    def is_enrollment_open(self):
        return self.enrollment_start <= timezone.now() <= self.enrollment_end

    def has_enrollment_closed(self):
        return timezone.now() > self.enrollment_end

    def is_visible_to(self, user=None):
        if self.visible_to_students:
            return True
        return user and self.is_course_staff(user)

    @property
    def head_css_urls(self):
        return [url for url in self.head_urls.split() if ".css" in url]

    @property
    def head_js_urls(self):
        return [url for url in self.head_urls.split() if ".js" in url]

    ABSOLUTE_URL_NAME = "course"
    EDIT_URL_NAME = "course-edit"

    def get_url_kwargs(self):
        # dict(foo=bar, **baz()) is not nice, but it's cleanest solution for this
        # specific problem. For more read out stackoverflow answer about merging
        # python dicts in single line: http://stackoverflow.com/a/26853961
        return dict(instance_slug=self.url, **self.course.get_url_kwargs())
Exemple #17
0
class CourseInstance(UrlMixin, models.Model):
    """
    CourseInstance class represent an instance of a course. A single course may have
    several instances either at the same time or during different years. All instances
    have the same teacher, but teaching assistants and students are connected to individual
    instances.
    """
    ENROLLMENT_AUDIENCE = Enum([
        ('INTERNAL_USERS', 1, _('Internal users')),
        ('EXTERNAL_USERS', 2, _('External users')),
        ('ALL_USERS', 3, _('Internal and external users')),
    ])
    VIEW_ACCESS = Enum([
        ('ENROLLED', 1, _('Enrolled students')),
        ('ENROLLMENT_AUDIENCE', 2, _('Enrollment audience')),
        ('ALL_REGISTERED', 3, _('All registered users')),
        ('PUBLIC', 4, _('Public to internet')),
    ])
    INDEX_TYPE = Enum([
        ('RESULTS', 0, _('User results')),
        ('TOC', 1, _("Table of contents")),
        ('LAST', 2, _("Link last visited content")),
        ('EXPERIMENT', 10, _("Experimental setup (hard-coded)")),
    ])
    CONTENT_NUMBERING = Enum([
        ('NONE', 0, _("No numbering")),
        ('ARABIC', 1, _("Arabic")),
        ('ROMAN', 2, _("Roman")),
        ('HIDDEN', 3, _("Hidden arabic")),
    ])

    course = models.ForeignKey(Course, related_name="instances")
    instance_name = models.CharField(max_length=255)
    url = models.CharField(
        max_length=255,
        blank=False,
        validators=[RegexValidator(regex="^[\w\-\.]*$")],
        help_text=_("Input an URL identifier for this course instance."))
    visible_to_students = models.BooleanField(default=True)
    enrollment_audience = models.IntegerField(
        choices=ENROLLMENT_AUDIENCE.choices,
        default=ENROLLMENT_AUDIENCE.INTERNAL_USERS)
    view_content_to = models.IntegerField(
        choices=VIEW_ACCESS.choices, default=VIEW_ACCESS.ENROLLMENT_AUDIENCE)
    starting_time = models.DateTimeField()
    ending_time = models.DateTimeField()
    enrollment_starting_time = models.DateTimeField(blank=True, null=True)
    enrollment_ending_time = models.DateTimeField(blank=True, null=True)
    image = models.ImageField(blank=True,
                              null=True,
                              upload_to=build_upload_dir)
    language = models.CharField(max_length=5, blank=True, default="")
    description = models.TextField(blank=True)
    footer = models.TextField(blank=True)
    index_mode = models.IntegerField(
        choices=INDEX_TYPE.choices,
        default=INDEX_TYPE.RESULTS,
        help_text=_('Select content for the course index page.'))
    module_numbering = models.IntegerField(choices=CONTENT_NUMBERING.choices,
                                           default=CONTENT_NUMBERING.ARABIC)
    content_numbering = models.IntegerField(choices=CONTENT_NUMBERING.choices,
                                            default=CONTENT_NUMBERING.ARABIC)
    head_urls = models.TextField(blank=True,
                                 help_text=_(
                                     "External CSS and JS resources "
                                     "that are included on all course pages. "
                                     "Separate with white space."))
    configure_url = models.URLField(blank=True)
    build_log_url = models.URLField(blank=True)
    technical_error_emails = models.CharField(
        max_length=255,
        blank=True,
        help_text=_(
            "By default exercise errors are reported to teacher "
            "email addresses. Set this field as comma separated emails to "
            "override the recipients."))
    plugins = generic.GenericRelation(BasePlugin,
                                      object_id_field="container_pk",
                                      content_type_field="container_type")
    tabs = generic.GenericRelation(BaseTab,
                                   object_id_field="container_pk",
                                   content_type_field="container_type")

    assistants = models.ManyToManyField(UserProfile,
                                        related_name="assisting_courses",
                                        blank=True)
    students = models.ManyToManyField(UserProfile,
                                      related_name="enrolled",
                                      blank=True,
                                      through='Enrollment')
    # usertags from course.models.UserTag
    # taggings from course.models.UserTagging
    # categories from course.models.LearningObjectCategory
    # course_modules from course.models.CourseModule

    objects = CourseInstanceManager()

    class Meta:
        unique_together = ("course", "url")

    def __str__(self):
        return "{}: {}".format(str(self.course), self.instance_name)

    def clean(self):
        """
        Validates the model before saving (standard method used in Django admin).
        """
        if self.ending_time <= self.starting_time:
            raise ValidationError({
                'ending_time':
                _("Ending time must be later than starting time.")
            })

    def save(self, *args, **kwargs):
        """
        Saves the model.
        """
        super().save(*args, **kwargs)
        if self.image:
            resize_image(self.image.path, (800, 600))

    def is_assistant(self, user):
        return (user and user.is_authenticated() and isinstance(user, User)
                and self.assistants.filter(id=user.userprofile.id).exists())

    def is_teacher(self, user):
        return self.course.is_teacher(user)

    def is_course_staff(self, user):
        return self.is_teacher(user) or self.is_assistant(user)

    def is_student(self, user):
        return (user and user.is_authenticated() and isinstance(user, User)
                and self.students.filter(id=user.userprofile.id).exists())

    def is_enrollable(self, user):
        if user and user.is_authenticated():
            if self.enrollment_audience == self.ENROLLMENT_AUDIENCE.INTERNAL_USERS:
                return not user.userprofile.is_external
            if self.enrollment_audience == self.ENROLLMENT_AUDIENCE.EXTERNAL_USERS:
                return user.userprofile.is_external
            return True
        return False

    def enroll_student(self, user):
        if user and user.is_authenticated():
            Enrollment.objects.get_or_create(course_instance=self,
                                             user_profile=user.userprofile)

    def tag_user(self, user, tag):
        UserTagging.objects.create(tag=tag,
                                   user=user.userprofile,
                                   course_instance=self)

    def get_enrollment_for(self, user):
        return Enrollment.objects.filter(
            course_instance=self, user_profile=user.userprofile).first()

    def get_user_tags(self, user):
        return self.taggings.filter(
            user=user.uesrprofile).select_related('tag')

    def get_course_staff_profiles(self):
        return UserProfile.objects.filter(Q(teaching_courses=self.course) | Q(assisting_courses=self))\
            .distinct()

    def get_student_profiles(self):
        return self.students.all()

    def get_submitted_profiles(self):
        return UserProfile.objects.filter(submissions__exercise__course_module__course_instance=self)\
            .distinct()\
            .exclude(assisting_courses=self)\
            .exclude(teaching_courses=self.course)

    def is_open(self, when=None):
        when = when or timezone.now()
        return self.starting_time <= when <= self.ending_time

    def is_past(self, when=None):
        when = when or timezone.now()
        return self.ending_time < when

    @property
    def enrollment_start(self):
        return self.enrollment_starting_time or self.starting_time

    @property
    def enrollment_end(self):
        return self.enrollment_ending_time or self.ending_time

    def is_enrollment_open(self):
        return self.enrollment_start <= timezone.now() <= self.enrollment_end

    def has_enrollment_closed(self):
        return timezone.now() > self.enrollment_end

    def is_visible_to(self, user=None):
        if self.visible_to_students:
            return True
        return user and self.is_course_staff(user)

    @property
    def head_css_urls(self):
        return [url for url in self.head_urls.split() if ".css" in url]

    @property
    def head_js_urls(self):
        return [url for url in self.head_urls.split() if ".js" in url]

    ABSOLUTE_URL_NAME = "course"
    EDIT_URL_NAME = "course-edit"

    def get_url_kwargs(self):
        # dict(foo=bar, **baz()) is not nice, but it's cleanest solution for this
        # specific problem. For more read out stackoverflow answer about merging
        # python dicts in single line: http://stackoverflow.com/a/26853961
        return dict(instance_slug=self.url, **self.course.get_url_kwargs())
Exemple #18
0
class Enrollment(models.Model):
    """
    Maps an enrolled student in a course instance.
    """
    ENROLLMENT_ROLE = Enum([
        ('STUDENT', 1, _('STUDENT')),
        ('ASSISTANT', 2, _('ASSISTANT')),
        ('TEACHER', 3, _('TEACHER')),
    ])
    ENROLLMENT_STATUS = Enum([
        ('ACTIVE', 1, _('ACTIVE')),
        ('REMOVED', 2, _('REMOVED')),
        ('BANNED', 3, _('BANNED')),
    ])

    course_instance = models.ForeignKey(
        'CourseInstance',
        verbose_name=_('LABEL_COURSE_INSTANCE'),
        on_delete=models.CASCADE,
    )
    user_profile = models.ForeignKey(
        UserProfile,
        verbose_name=_('LABEL_USER_PROFILE'),
        on_delete=models.CASCADE,
    )
    language = models.CharField(
        verbose_name=_('LABEL_LANGUAGE'),
        max_length=5,
        blank=True,
        default="",
    )
    timestamp = models.DateTimeField(
        verbose_name=_('LABEL_TIMESTAMP'),
        auto_now_add=True,
    )
    personal_code = models.CharField(
        verbose_name=_('LABEL_PERSONAL_CODE'),
        max_length=10,
        blank=True,
        default='',
    )
    selected_group = models.ForeignKey(
        StudentGroup,
        verbose_name=_('LABEL_SELECTED_GROUP'),
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        default=None,
    )
    anon_name = models.CharField(
        verbose_name=_('LABEL_ANON_NAME'),
        max_length=50,
        blank=True,
        default='',
    )
    anon_id = models.CharField(
        verbose_name=_('LABEL_ANON_ID'),
        max_length=50,
        unique=True,
        blank=True,
        null=True,
    )
    role = models.IntegerField(
        verbose_name=_('LABEL_ROLE'),
        choices=ENROLLMENT_ROLE.choices,
        default=ENROLLMENT_ROLE.STUDENT,
    )
    status = models.IntegerField(
        verbose_name=_('LABEL_STATUS'),
        choices=ENROLLMENT_STATUS.choices,
        default=ENROLLMENT_STATUS.ACTIVE,
    )
    from_sis = models.BooleanField(
        verbose_name=_('LABEL_FROM_SIS'),
        default=False,
    )

    class Meta:
        verbose_name = _('MODEL_NAME_ENROLLMENT')
        verbose_name_plural = _('MODEL_NAME_ENROLLMENT_PLURAL')
        unique_together = ("course_instance", "user_profile")
Exemple #19
0
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")
Exemple #20
0
class CourseModule(UrlMixin, models.Model):
    """
    CourseModule objects connect chapters and learning objects to logical sets
    of each other and course instances. They also contain information about the
    opening times and deadlines for exercises.
    """
    STATUS = Enum([
        ('READY', 'ready', _('STATUS_READY')),
        ('UNLISTED', 'unlisted', _('STATUS_UNLISTED')),
        ('HIDDEN', 'hidden', _('STATUS_HIDDEN')),
        ('MAINTENANCE', 'maintenance', _('STATUS_MAINTENANCE')),
    ])
    status = models.CharField(
        verbose_name=_('LABEL_STATUS'),
        max_length=32,
        choices=STATUS.choices,
        default=STATUS.READY,
    )
    order = models.IntegerField(
        verbose_name=_('LABEL_ORDER'),
        default=1,
    )
    name = models.CharField(
        verbose_name=_('LABEL_NAME'),
        max_length=255,
    )
    url = models.CharField(
        verbose_name=_('LABEL_URL'),
        max_length=255,
        help_text=_('MODULE_URL_IDENTIFIER_HELPTEXT'),
        validators=[generate_url_key_validator()],
    )
    points_to_pass = models.PositiveIntegerField(
        verbose_name=_('LABEL_POINTS_TO_PASS'), default=0)
    introduction = models.TextField(
        verbose_name=_('LABEL_INTRODUCTION'),
        blank=True,
    )
    course_instance = models.ForeignKey(
        CourseInstance,
        verbose_name=_('LABEL_COURSE_INSTANCE'),
        on_delete=models.CASCADE,
        related_name="course_modules",
    )
    reading_opening_time = models.DateTimeField(
        verbose_name=_('LABEL_READING_OPENING_TIME'),
        blank=True,
        null=True,
        help_text=_('MODULE_READING_OPENING_TIME_HELPTEXT'),
    )
    opening_time = models.DateTimeField(
        verbose_name=_('LABEL_EXERCISE_OPENING_TIME'), default=timezone.now)
    closing_time = models.DateTimeField(
        verbose_name=_('LABEL_CLOSING_TIME'),
        default=timezone.now,
        help_text=_('MODULE_CLOSING_TIME_HELPTEXT'),
    )

    # early_submissions_allowed= models.BooleanField(default=False)
    # early_submissions_start = models.DateTimeField(default=timezone.now, blank=True, null=True)
    # early_submission_bonus  = PercentField(default=0.1,
    #   help_text=_("Multiplier of points to reward, as decimal. 0.1 = 10%"))

    late_submissions_allowed = models.BooleanField(
        verbose_name=_('LABEL_LATE_SUBMISSIONS_ALLOWED'),
        default=False,
    )
    late_submission_deadline = models.DateTimeField(
        verbose_name=_('LABEL_LATE_SUBMISSION_DEADLINE'),
        default=timezone.now,
    )
    late_submission_penalty = PercentField(
        verbose_name=_('LABEL_LATE_SUBMISSION_PENALTY'),
        default=0.5,
        help_text=_('MODULE_LATE_SUBMISSION_PENALTY_HELPTEXT'),
    )

    objects = CourseModuleManager()

    class Meta:
        verbose_name = _('MODEL_NAME_COURSE_MODULE')
        verbose_name_plural = _('MODEL_NAME_COURSE_MODULE_PLURAL')
        unique_together = ("course_instance", "url")
        ordering = ['order', 'closing_time', 'id']

    def __str__(self):
        if self.order > 0:
            if self.course_instance.module_numbering == CourseInstance.CONTENT_NUMBERING.ARABIC:
                return "{:d}. {}".format(self.order, self.name)
            elif self.course_instance.module_numbering == CourseInstance.CONTENT_NUMBERING.ROMAN:
                return "{} {}".format(roman_numeral(self.order), self.name)
        return self.name

    def clean(self):
        super().clean()
        errors = {}
        RESERVED = ("toc", "teachers", "user", "exercises", "apps",
                    "lti-login")
        if self.url in RESERVED:
            errors['url'] = format_lazy(_('TAKEN_WORDS_INCLUDE -- {}'),
                                        ", ".join(RESERVED))
        if self.opening_time > self.closing_time:
            errors['opening_time'] = _(
                'MODULE_ERROR_OPENING_TIME_AFTER_CLOSING_TIME')
        if self.late_submissions_allowed and self.late_submission_deadline <= self.closing_time:
            errors['late_submission_deadline'] = _(
                'MODULE_ERROR_LATE_SUBMISSION_DL_BEFORE_CLOSING_TIME')
        if self.reading_opening_time and self.reading_opening_time > self.opening_time:
            errors['reading_opening_time'] = _(
                'MODULE_ERROR_READING_OPENING_TIME_AFTER_EXERCISE_OPENING')
        if errors:
            raise ValidationError(errors)

    def is_open(self, when=None):
        when = when or timezone.now()
        if self.reading_opening_time:
            return self.reading_opening_time <= when <= self.closing_time
        return self.opening_time <= when <= self.closing_time

    def is_after_open(self, when=None):
        """
        Checks if current time is past the round opening time.
        """
        when = when or timezone.now()
        if self.reading_opening_time:
            return self.reading_opening_time <= when
        return self.opening_time <= when

    def have_exercises_been_opened(self, when=None):
        when = when or timezone.now()
        return self.opening_time <= when

    def exercises_open(self, when=None):
        when = when or timezone.now()
        return self.opening_time <= when <= self.closing_time

    def is_late_submission_open(self, when=None):
        when = when or timezone.now()
        return self.late_submissions_allowed \
            and self.closing_time <= when <= self.late_submission_deadline

    def is_closed(self, when=None):
        when = when or timezone.now()
        if self.late_submissions_allowed and self.late_submission_penalty < 1:
            return when > self.late_submission_deadline
        return when > self.closing_time

    def are_requirements_passed(self, cached_points):
        for r in self.requirements.all():
            if not r.is_passed(cached_points):
                return False
        return True

    def get_late_submission_point_worth(self):
        """
        Returns the percentage (0-100) that late submission points are worth.
        """
        point_worth = 0
        if self.late_submissions_allowed:
            point_worth = int((1.0 - self.late_submission_penalty) * 100.0)
        return point_worth

    def number_of_submitters(self):
        return self.course_instance.students\
            .filter(submissions__exercise__course_module=self).distinct().count()

    ABSOLUTE_URL_NAME = "module"

    def get_url_kwargs(self):
        return dict(module_slug=self.url,
                    **self.course_instance.get_url_kwargs())
Exemple #21
0
class MenuItem(UrlMixin, models.Model):
    '''
    Attaches link to course menu.
    '''
    ACCESS = Enum([
        ('STUDENT', 0, _("All students, assistants and teachers can access.")),
        ('ASSISTANT', 5, _("Only assistants and teachers can access.")),
        ('TEACHER', 10, _("Only teachers can access.")),
    ])
    course_instance = models.ForeignKey(
        CourseInstance,
        related_name="ext_services",
        help_text=_("A course where the menu item exists.")
    )
    access = models.IntegerField(
        choices=ACCESS.choices,
        default=ACCESS.STUDENT,
    )
    service = models.ForeignKey(
        LinkService,
        blank=True,
        null=True,
        help_text=_("If preconfigured, an external service to link.")
    )
    menu_url = models.CharField(
        max_length=256,
        blank=True,
        null=True,
        help_text=_("A link URL (else service default). Relative URLs are relative to course root.")
    )
    menu_group_label = models.CharField(
        max_length=32,
        blank=True,
        null=True,
        help_text=_("Places menu item under a group label.")
    )
    menu_label = models.CharField(
        max_length=32,
        blank=True,
        null=True,
        help_text=_("Label for the menu link (else service default).")
    )
    menu_icon_class = models.CharField(
        max_length=32,
        null=True,
        blank=True,
        help_text=_("Menu icon style name (else service default), e.g. star see http://getbootstrap.com/components/#glyphicons-glyphs")
    )
    menu_weight = models.IntegerField(
        default=0,
        help_text=_("Heavier menu entries are placed after lighter ones.")
    )
    enabled = models.BooleanField(default=True)

    class Meta:
        ordering = ["course_instance", "menu_weight", "menu_label"]

    def __str__(self):
        out = self.label
        if not self.is_enabled:
            return "[Disabled] " + out
        return out

    def clean(self):
        if not self.service and not (self.menu_url and self.menu_label):
            raise ValidationError(_("Either preconfigured service or custom URL and label needs to be provided."))

    @property
    def is_enabled(self):
        if self.service:
            return self.service.enabled and self.enabled
        return self.enabled

    @property
    def label(self):
        if self.menu_label:
            return self.menu_label
        return self.service.menu_label

    @property
    def icon_class(self):
        if self.menu_icon_class:
            return self.menu_icon_class
        if self.service:
            return self.service.menu_icon_class
        return ""

    @property
    def url(self):
        if self.menu_url:
            if re.search(r"^\w+:\/\/", self.menu_url):
                return self.menu_url
            return "{}{}".format(
                self.course_instance.get_absolute_url(),
                self.menu_url[1:] if self.menu_url.startswith("/") else self.menu_url
            )
        if self.is_lti_service():
            instance = self.course_instance
            return reverse('lti-login', kwargs={
                "course_slug": instance.course.url,
                "instance_slug": instance.url,
                "menu_id": self.id,
            })
        return self.service.url

    def is_lti_service(self):
        if not hasattr(self, '_is_lti'):
            self._is_lti = (self.service
                and isinstance(self.service.as_leaf_class(), LTIService))
        return self._is_lti

    def get_url_kwargs(self):
        return dict(menu_id=self.id, **self.course_instance.get_url_kwargs())
Exemple #22
0
class CourseModule(UrlMixin, models.Model):
    """
    CourseModule objects connect chapters and learning objects to logical sets
    of each other and course instances. They also contain information about the
    opening times and deadlines for exercises.
    """
    STATUS = Enum([
        ('READY', 'ready', _("Ready")),
        ('UNLISTED', 'unlisted', _("Unlisted in table of contents")),
        ('HIDDEN', 'hidden', _("Hidden")),
        ('MAINTENANCE', 'maintenance', _("Maintenance")),
    ])
    status = models.CharField(max_length=32,
                              choices=STATUS.choices,
                              default=STATUS.READY,
                              verbose_name='当前状态')
    order = models.IntegerField(default=1, verbose_name='序号')
    name = models.CharField(max_length=255, verbose_name='名称')
    url = models.CharField(
        max_length=255,
        validators=[generate_url_key_validator()],
        help_text=_("Input an URL identifier for this module."),
        verbose_name='utl标识符')
    points_to_pass = models.PositiveIntegerField(default=0,
                                                 verbose_name='通过分数')
    introduction = models.TextField(blank=True, verbose_name='描述')
    course_instance = models.ForeignKey(CourseInstance,
                                        on_delete=models.CASCADE,
                                        related_name="course_modules",
                                        verbose_name='课程实例')
    opening_time = models.DateTimeField(default=timezone.now,
                                        verbose_name='课程开放时间')
    closing_time = models.DateTimeField(default=timezone.now,
                                        verbose_name='课程结束时间')

    # early_submissions_allowed= models.BooleanField(default=False)
    # early_submissions_start = models.DateTimeField(default=timezone.now, blank=True, null=True)
    # early_submission_bonus  = PercentField(default=0.1,
    #   help_text=_("Multiplier of points to reward, as decimal. 0.1 = 10%"))

    late_submissions_allowed = models.BooleanField(default=False,
                                                   verbose_name='是否允许逾期提交')
    late_submission_deadline = models.DateTimeField(default=timezone.now,
                                                    verbose_name='逾期提交截止时间')
    late_submission_penalty = PercentField(
        default=0.5,
        help_text=_("Multiplier of points to reduce, as decimal. 0.1 = 10%"),
        verbose_name='逾期提交惩罚')

    objects = CourseModuleManager()

    class Meta:
        unique_together = ("course_instance", "url")
        ordering = ['order', 'closing_time', 'id']
        verbose_name = '课程模型'
        verbose_name_plural = verbose_name

    def __str__(self):
        if self.order > 0:
            if self.course_instance.module_numbering == CourseInstance.CONTENT_NUMBERING.ARABIC:
                return "{:d}. {}".format(self.order, self.name)
            elif self.course_instance.module_numbering == CourseInstance.CONTENT_NUMBERING.ROMAN:
                return "{} {}".format(roman_numeral(self.order), self.name)
        return self.name

    def clean(self):
        super().clean()
        errors = {}
        RESERVED = ("toc", "teachers", "user", "exercises", "apps",
                    "lti-login")
        if self.url in RESERVED:
            errors['url'] = _("Taken words include: {}").format(
                ", ".join(RESERVED))
        if self.opening_time > self.closing_time:
            errors['opening_time'] = _(
                "Opening time must be earlier than the closing time.")
        if self.late_submissions_allowed and self.late_submission_deadline <= self.closing_time:
            errors['late_submission_deadline'] = _(
                "Late submission deadline must be later than the closing time."
            )
        if errors:
            raise ValidationError(errors)

    def is_open(self, when=None):
        when = when or timezone.now()
        return self.opening_time <= when <= self.closing_time

    def is_after_open(self, when=None):
        """
        Checks if current time is past the round opening time.
        """
        when = when or timezone.now()
        return self.opening_time <= when

    def is_late_submission_open(self, when=None):
        when = when or timezone.now()
        return self.late_submissions_allowed \
            and self.closing_time <= when <= self.late_submission_deadline

    def is_closed(self, when=None):
        when = when or timezone.now()
        if self.late_submissions_allowed and self.late_submission_penalty < 1:
            return when > self.late_submission_deadline
        return when > self.closing_time

    def are_requirements_passed(self, cached_points):
        for r in self.requirements.all():
            if not r.is_passed(cached_points):
                return False
        return True

    def get_late_submission_point_worth(self):
        """
        Returns the percentage (0-100) that late submission points are worth.
        """
        point_worth = 0
        if self.late_submissions_allowed:
            point_worth = int((1.0 - self.late_submission_penalty) * 100.0)
        return point_worth

    def number_of_submitters(self):
        return self.course_instance.students\
            .filter(submissions__exercise__course_module=self).distinct().count()

    ABSOLUTE_URL_NAME = "module"

    def get_url_kwargs(self):
        return dict(module_slug=self.url,
                    **self.course_instance.get_url_kwargs())
Exemple #23
0
                "after calling it with it first. Fix your code by removing "
                "the first method call and add replace=True to the second method call too."
            )
            self.message = format_lazy('{}{}{}', self.message, delim, message)


# Access mode
# ===========

# All access levels
ACCESS = Enum(
    ('ANONYMOUS', 0, _("Any user authenticated or not")),
    ('ENROLL', 1, None),
    ('STUDENT', 3, _("Any authenticated student")),
    ('ENROLLED', 4, _("Enrolled student of the course")),
    ('ASSISTANT', 5, _("Assistant of the course")),
    ('GRADING', 6,
     _("Grading. Assistant if course has that option or teacher")),
    ('TEACHER', 10, _("Teacher of the course")),
    ('SUPERUSER', 100, _("Superuser of the service")),
)


class AccessModePermission(MessageMixin, Permission):
    """
    If view has access_mode that is not anonymous, then require authentication
    """
    message = _("Permission denied by access mode.")

    def has_permission(self, request, view):
        access_mode = view.get_access_mode()
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()]
Exemple #25
0
class CourseModule(UrlMixin, models.Model):
    """
    CourseModule objects connect chapters and learning objects to logical sets
    of each other and course instances. They also contain information about the
    opening times and deadlines for exercises.
    """
    STATUS = Enum([
        ('READY', 'ready', _("Ready")),
        ('UNLISTED', 'unlisted', _("Unlisted in table of contents")),
        ('HIDDEN', 'hidden', _("Hidden")),
        ('MAINTENANCE', 'maintenance', _("Maintenance")),
    ])
    status = models.CharField(max_length=32,
                              choices=STATUS.choices,
                              default=STATUS.READY)
    order = models.IntegerField(default=1)
    name = models.CharField(max_length=255)
    url = models.CharField(
        max_length=255,
        validators=[RegexValidator(regex="^[\w\-\.]*$")],
        help_text=_("Input an URL identifier for this module."))
    points_to_pass = models.PositiveIntegerField(default=0)
    introduction = models.TextField(blank=True)
    course_instance = models.ForeignKey(CourseInstance,
                                        related_name="course_modules")
    opening_time = models.DateTimeField(default=timezone.now)
    closing_time = models.DateTimeField(default=timezone.now)

    # early_submissions_allowed= models.BooleanField(default=False)
    # early_submissions_start = models.DateTimeField(default=timezone.now, blank=True, null=True)
    # early_submission_bonus  = PercentField(default=0.1,
    #   help_text=_("Multiplier of points to reward, as decimal. 0.1 = 10%"))

    late_submissions_allowed = models.BooleanField(default=False)
    late_submission_deadline = models.DateTimeField(default=timezone.now)
    late_submission_penalty = PercentField(
        default=0.5,
        help_text=_("Multiplier of points to reduce, as decimal. 0.1 = 10%"))

    objects = CourseModuleManager()

    class Meta:
        unique_together = ("course_instance", "url")
        ordering = ['order', 'closing_time', 'id']

    def __str__(self):
        if self.order > 0:
            if self.course_instance.module_numbering == CourseInstance.CONTENT_NUMBERING.ARABIC:
                return "{:d}. {}".format(self.order, self.name)
            elif self.course_instance.module_numbering == CourseInstance.CONTENT_NUMBERING.ROMAN:
                return "{} {}".format(roman_numeral(self.order), self.name)
        return self.name

    def clean(self):
        """
        Validates the model before saving (standard method used in Django admin).
        """
        RESERVED = ("toc", "teachers", "user", "exercises", "apps",
                    "lti-login")
        if self.url in RESERVED:
            raise ValidationError({
                'url':
                _("Taken words include: {}").format(", ".join(RESERVED))
            })

    def is_open(self, when=None):
        when = when or timezone.now()
        return self.opening_time <= when <= self.closing_time

    def is_after_open(self, when=None):
        """
        Checks if current time is past the round opening time.
        """
        when = when or timezone.now()
        return self.opening_time <= when

    def is_late_submission_open(self, when=None):
        when = when or timezone.now()
        return self.late_submissions_allowed \
            and self.closing_time <= when <= self.late_submission_deadline

    def is_closed(self, when=None):
        when = when or timezone.now()
        if self.late_submissions_allowed and self.late_submission_penalty < 1:
            return when > self.late_submission_deadline
        return when > self.closing_time

    def are_requirements_passed(self, cached_points):
        for r in self.requirements.all():
            if not r.is_passed(cached_points):
                return False
        return True

    def get_late_submission_point_worth(self):
        """
        Returns the percentage (0-100) that late submission points are worth.
        """
        point_worth = 0
        if self.late_submissions_allowed:
            point_worth = int((1.0 - self.late_submission_penalty) * 100.0)
        return point_worth

    ABSOLUTE_URL_NAME = "module"

    def get_url_kwargs(self):
        return dict(module_slug=self.url,
                    **self.course_instance.get_url_kwargs())
Exemple #26
0
class CourseInstance(UrlMixin, models.Model):
    """
    CourseInstance class represent an instance of a course. A single course may have
    several instances either at the same time or during different years. All instances
    have the same teacher, but teaching assistants and students are connected to individual
    instances.
    """
    ENROLLMENT_AUDIENCE = Enum([
        ('INTERNAL_USERS', 1, _('Internal users')),
        ('EXTERNAL_USERS', 2, _('External users')),
        ('ALL_USERS', 3, _('Internal and external users')),
    ])
    VIEW_ACCESS = Enum([
        ('ENROLLED', 1, _('Enrolled students')),
        ('ENROLLMENT_AUDIENCE', 2, _('Enrollment audience')),
        ('ALL_REGISTERED', 3, _('All registered users')),
        ('PUBLIC', 4, _('Public to internet')),
    ])
    INDEX_TYPE = Enum([
        ('RESULTS', 0, _('User results')),
        ('TOC', 1, _("Table of contents")),
        ('LAST', 2, _("Link to last visited content")),
        ('EXPERIMENT', 10, _("Experimental setup (hard-coded)")),
    ])
    CONTENT_NUMBERING = Enum([
        ('NONE', 0, _("No numbering")),
        ('ARABIC', 1, _("Arabic")),
        ('ROMAN', 2, _("Roman")),
        ('HIDDEN', 3, _("Hidden arabic")),
    ])

    course = models.ForeignKey(Course,
                               on_delete=models.CASCADE,
                               related_name="instances")
    instance_name = models.CharField(max_length=255, verbose_name='课程实例')
    url = models.CharField(
        max_length=255,
        verbose_name='URL标识',
        blank=False,
        validators=[generate_url_key_validator()],
        help_text=_("Input an URL identifier for this course instance."))
    visible_to_students = models.BooleanField(default=True,
                                              verbose_name='公开课程')
    enrollment_audience = models.IntegerField(
        choices=ENROLLMENT_AUDIENCE.choices,
        default=ENROLLMENT_AUDIENCE.INTERNAL_USERS,
        verbose_name='注册对象')
    view_content_to = models.IntegerField(choices=VIEW_ACCESS.choices,
                                          default=VIEW_ACCESS.ENROLLED,
                                          verbose_name='内容可见')
    starting_time = models.DateTimeField('开课时间')
    ending_time = models.DateTimeField('结课时间')
    lifesupport_time = models.DateTimeField(blank=True,
                                            null=True,
                                            verbose_name='结业时间')
    archive_time = models.DateTimeField(blank=True,
                                        null=True,
                                        verbose_name='归档时间')
    enrollment_starting_time = models.DateTimeField(blank=True,
                                                    null=True,
                                                    verbose_name='开放注册时间')
    enrollment_ending_time = models.DateTimeField(blank=True,
                                                  null=True,
                                                  verbose_name='关闭注册时间')
    image = models.ImageField(blank=True,
                              null=True,
                              upload_to=build_upload_dir,
                              verbose_name='缩略图')
    language = models.CharField(max_length=255,
                                blank=True,
                                default="zh-hans",
                                verbose_name='语言')
    description = models.TextField(blank=True, verbose_name='描述')
    footer = models.TextField(blank=True, verbose_name='页脚')
    index_mode = models.IntegerField(
        choices=INDEX_TYPE.choices,
        default=INDEX_TYPE.RESULTS,
        verbose_name=_('Index mode'),
        help_text=_('Select content for the course index page.'))
    module_numbering = models.IntegerField(choices=CONTENT_NUMBERING.choices,
                                           default=CONTENT_NUMBERING.ARABIC,
                                           verbose_name='模块编号')
    content_numbering = models.IntegerField(choices=CONTENT_NUMBERING.choices,
                                            default=CONTENT_NUMBERING.ARABIC,
                                            verbose_name='章节编号')
    head_urls = models.TextField(blank=True,
                                 verbose_name='资源URLs',
                                 help_text=_(
                                     "External CSS and JS resources "
                                     "that are included on all course pages. "
                                     "Separate with white space."))
    configure_url = models.URLField(blank=True)
    build_log_url = models.URLField(blank=True)
    last_modified = models.DateTimeField(auto_now=True, blank=True, null=True)
    technical_error_emails = models.CharField(
        max_length=255,
        blank=True,
        verbose_name='错误邮箱',
        help_text=_(
            "By default exercise errors are reported to teacher "
            "email addresses. Set this field as comma separated emails to "
            "override the recipients."))
    plugins = GenericRelation(BasePlugin,
                              object_id_field="container_pk",
                              content_type_field="container_type")
    tabs = GenericRelation(BaseTab,
                           object_id_field="container_pk",
                           content_type_field="container_type")

    assistants = models.ManyToManyField(UserProfile,
                                        related_name="assisting_courses",
                                        verbose_name='选择助教',
                                        blank=True)
    students = models.ManyToManyField(UserProfile,
                                      related_name="enrolled",
                                      blank=True,
                                      through='Enrollment')
    # usertags from course.models.UserTag
    # taggings from course.models.UserTagging
    # categories from course.models.LearningObjectCategory
    # course_modules from course.models.CourseModule

    objects = CourseInstanceManager()

    class Meta:
        unique_together = ("course", "url")
        verbose_name = '课程实例'
        verbose_name_plural = verbose_name

    def __str__(self):
        return "{}: {}".format(str(self.course), self.instance_name)

    def clean(self):
        super().clean()
        errors = {}
        if self.ending_time <= self.starting_time:
            errors['ending_time'] = _(
                "Ending time must be later than starting time.")
        if self.lifesupport_time and self.lifesupport_time < self.ending_time:
            errors['lifesupport_time'] = _(
                "Lifesupport time must be later than ending time.")
        if (self.archive_time and not self.lifesupport_time) \
                or (self.lifesupport_time and not self.archive_time):
            # Must not set only one of lifesupport and archive time since their
            # default values could change their order. Lifesupport time must not
            # be earlier than archive time.
            errors['archive_time'] = _(
                "Lifesupport time and archive time must be either both set or both unset."
            )
        elif self.archive_time and self.archive_time < self.lifesupport_time:
            errors['archive_time'] = _(
                "Archive time must be later than lifesupport time.")
        if self.language.startswith("|"):
            langs = list(filter(
                None,
                self.language.split("|")))  # remove pipes & empty strings
            for lang in langs:
                if not self.is_valid_language(lang):
                    if "language" in errors:
                        errors['language'] += (", " + lang)
                    else:
                        errors['language'] = _(
                            "Language code(s) missing from settings: ") + lang
        elif not self.is_valid_language(self.language):
            errors['language'] = _("Language code missing from settings.")
        if errors:
            raise ValidationError(errors)

    def is_valid_language(self, lang):
        return lang == "" or lang in [key for key, name in settings.LANGUAGES]

    def save(self, *args, **kwargs):
        """
        Saves the model.
        """
        super().save(*args, **kwargs)
        if self.image:
            resize_image(self.image.path, (800, 600))

    def is_assistant(self, user):
        return (user and user.is_authenticated and isinstance(user, User)
                and self.assistants.filter(id=user.userprofile.id).exists())

    def is_teacher(self, user):
        return self.course.is_teacher(user)

    def is_course_staff(self, user):
        return self.is_teacher(user) or self.is_assistant(user)

    def is_student(self, user):
        return (user and user.is_authenticated and isinstance(user, User)
                and self.students.filter(id=user.userprofile.id).exists())

    def is_enrollable(self, user):
        if user and user.is_authenticated and self.visible_to_students:
            if self.enrollment_audience == self.ENROLLMENT_AUDIENCE.INTERNAL_USERS:
                return not user.userprofile.is_external
            if self.enrollment_audience == self.ENROLLMENT_AUDIENCE.EXTERNAL_USERS:
                return user.userprofile.is_external
            return True
        return False

    def enroll_student(self, user):
        if user and user.is_authenticated:
            Enrollment.objects.get_or_create(course_instance=self,
                                             user_profile=user.userprofile)

    def tag_user(self, user, tag):
        UserTagging.objects.create(tag=tag,
                                   user=user.userprofile,
                                   course_instance=self)

    def get_enrollment_for(self, user):
        return Enrollment.objects.filter(
            course_instance=self, user_profile=user.userprofile).first()

    def get_user_tags(self, user):
        return self.taggings.filter(
            user=user.uesrprofile).select_related('tag')

    def get_course_staff_profiles(self):
        return UserProfile.objects.filter(Q(teaching_courses=self.course) | Q(assisting_courses=self))\
            .distinct()

    def get_student_profiles(self):
        return self.students.all()

    def get_submitted_profiles(self):
        return UserProfile.objects.filter(submissions__exercise__course_module__course_instance=self)\
            .distinct()\
            .exclude(assisting_courses=self)\
            .exclude(teaching_courses=self.course)

    def is_open(self, when=None):
        when = when or timezone.now()
        return self.starting_time <= when <= self.ending_time

    def is_past(self, when=None):
        when = when or timezone.now()
        return self.ending_time < when

    def is_on_lifesupport(self, when=None):
        when = when or timezone.now()
        return self.lifesupport_start < when

    def is_archived(self, when=None):
        when = when or timezone.now()
        return self.archive_start < when

    @property
    def archive_start(self):
        if self.archive_time:  # not null
            return self.archive_time
        return self.ending_time + datetime.timedelta(days=365)

    @property
    def lifesupport_start(self):
        if self.lifesupport_time:  # not null
            return self.lifesupport_time
        return self.ending_time + datetime.timedelta(days=365)

    @property
    def enrollment_start(self):
        return self.enrollment_starting_time or self.starting_time

    @property
    def enrollment_end(self):
        return self.enrollment_ending_time or self.ending_time

    def is_enrollment_open(self):
        return self.enrollment_start <= timezone.now() <= self.enrollment_end

    def has_enrollment_closed(self):
        return timezone.now() > self.enrollment_end

    def is_visible_to(self, user=None):
        if self.visible_to_students:
            return True
        return user and self.is_course_staff(user)

    @property
    def head_css_urls(self):
        return [url for url in self.head_urls.split() if ".css" in url]

    @property
    def head_js_urls(self):
        return [url for url in self.head_urls.split() if ".js" in url]

    ABSOLUTE_URL_NAME = "course"
    EDIT_URL_NAME = "course-edit"

    def get_url_kwargs(self):
        # dict(foo=bar, **baz()) is not nice, but it's cleanest solution for this
        # specific problem. For more read out stackoverflow answer about merging
        # python dicts in single line: http://stackoverflow.com/a/26853961
        return dict(instance_slug=self.url, **self.course.get_url_kwargs())
Exemple #27
0
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 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")),
        ('ERROR', '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 = ['-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):
                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"))

        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.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.
        """

        # Evade bad max points in remote service.
        if max_points == 0 and points > 0:
            max_points = self.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

        # 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 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()
        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.exercise.confirm_the_level:
            return False
        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

    @property
    def is_graded(self):
        return self.status == self.STATUS.READY

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