Ejemplo n.º 1
0
class Feedback(models.Model):
    objects = FeedbackQuerySet.as_manager()

    class Meta:
        unique_together = [
            ('exercise', 'submission_id'),
        ]

    GRADES = Enum(
        ('NONE', -1,
         _('No response')),  # can't be stored in db (positive integers only)
        ('REJECTED', 0, _('Rejected')),
        ('ACCEPTED', 1, _('Accepted')),
        ('ACCEPTED_GOOD', 2, _('Good')),
    )
    GRADE_CHOICES = [x for x in GRADES.choices if x[0] >= 0]
    MAX_GRADE = GRADES.ACCEPTED_GOOD
    OK_GRADES = (GRADES.ACCEPTED, GRADES.ACCEPTED_GOOD)

    NOTIFY = Enum(
        ('NO', 0, _('No notification')),
        ('NORMAL', 1, _('Normal notification')),
        ('IMPORTANT', 2, _('Important notification')),
    )
    NOTIFY_APLUS = {
        NOTIFY.NORMAL: 'normal',
        NOTIFY.IMPORTANT: 'important',
    }

    # identifier
    exercise = models.ForeignKey(Exercise,
                                 related_name='feedbacks',
                                 on_delete=models.PROTECT)
    submission_id = models.IntegerField()
    path_key = models.CharField(max_length=255, db_index=True)
    max_grade = models.PositiveSmallIntegerField(default=MAX_GRADE)

    # feedback
    timestamp = models.DateTimeField(default=timezone.now, db_index=True)
    language = models.CharField(max_length=255,
                                default=get_language,
                                null=True)
    student = models.ForeignKey(Student,
                                related_name='feedbacks',
                                on_delete=models.CASCADE)
    form = models.ForeignKey(FeedbackForm,
                             related_name='feedbacks',
                             on_delete=models.PROTECT,
                             null=True)
    form_data = pg_fields.JSONField(blank=True)
    superseded_by = models.ForeignKey('self',
                                      related_name="supersedes",
                                      on_delete=models.SET_NULL,
                                      null=True)
    post_url = models.URLField()
    submission_url = models.URLField()
    submission_html_url = models.URLField()

    # response
    response_time = models.DateTimeField(null=True,
                                         verbose_name=_("Response time"))
    response_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                    related_name='responded_feedbacks',
                                    on_delete=models.SET_NULL,
                                    null=True,
                                    verbose_name=_("Responder"))
    response_msg = models.TextField(blank=True,
                                    null=True,
                                    default=None,
                                    verbose_name=_("Response message"))
    response_grade = models.PositiveSmallIntegerField(
        default=GRADES.REJECTED,
        choices=GRADE_CHOICES,
        verbose_name=_("Response grade"))
    response_notify = models.PositiveSmallIntegerField(
        default=NOTIFY.NO,
        choices=NOTIFY.choices,
        verbose_name=_("Response notify"))

    # response upload
    _response_upl_code = models.PositiveSmallIntegerField(
        default=0, db_column='response_upload_code')
    _response_upl_attempt = models.PositiveSmallIntegerField(
        default=0, db_column='response_upload_attempt')
    _response_upl_at = models.DateTimeField(null=True,
                                            db_column='response_upload_at')

    # Extra getters and properties

    @cached_property
    def course(self):
        return self.exercise.course

    @staticmethod
    def get_exercise_path(exercise, path_key):
        return "{}{}{}".format(
            exercise,
            "/" if path_key else "",
            path_key or '',
        )

    @cached_property
    def exercise_path(self):
        return self.get_exercise_path(self.exercise, self.path_key)

    @property
    def form_class(self):
        return self.form.form_class

    def get_form_class(self, dummy=True):
        return self.form.form_class_or_dummy if dummy else self.form.form_class

    @property
    def form_obj(self):
        return self.form_class(data=self.form_data)

    def get_form_obj(self, dummy=False):
        return self.get_form_class(dummy)(data=self.form_data)

    @property
    def text_feedback(self):
        form = self.get_form_obj(True)
        if form.is_dummy_form:
            return list(self.form_data.items())
        else:
            data = self.form_data
            return [(k, data[k]) for k in form.all_text_fields.keys()]

    @property
    def response_uploaded(self):
        when = self._response_upl_at
        code = self._response_upl_code
        attempts = self._response_upl_attempt
        ok = code in (200, )
        return ResponseUploaded(ok, when, code, attempts)

    @response_uploaded.setter
    def response_uploaded(self, status_code):
        if status_code:
            self._response_upl_code = status_code
            self._response_upl_attempt += 1
            self._response_upl_at = timezone.now()
        else:
            self._response_upl_code = 0
            self._response_upl_attempt = 0
            self._response_upl_at = None
        self.__changed_fields.update(
            ('_response_upl_code', '_response_upl_attempt',
             '_response_upl_at'))

    @property
    def responded(self):
        return self.response_time is not None

    @property
    def waiting_for_response(self):
        return not self.responded and not self.superseded_by_id

    @property
    def waiting_for_response_msg(self):
        return not self.response_msg and not self.superseded_by_id

    @property
    def can_be_responded(self):
        return bool(self.submission_url)

    @property
    def response_grade_text(self):
        if not self.responded:
            return self.GRADES[self.GRADES.NONE]
        return self.GRADES[self.response_grade]

    @property
    def valid_response_grade(self):
        if not self.responded:
            return None
        return self.response_grade

    @property
    def response_notify_aplus(self):
        return self.NOTIFY_APLUS.get(self.response_notify, '')

    @property
    def older_versions(self):
        return self.__class__.objects.filter(
            ~models.Q(pk=self.pk),
            exercise_id=self.exercise_id,
            student_id=self.student_id,
            timestamp__lt=self.timestamp,
        ).order_by('-timestamp')

    @cached_property
    def older_versions_with_message(self):
        return list(
            self.older_versions.filter(~models.Q(response_msg='')
                                       & ~models.Q(response_msg=None)))

    def __getitem__(self, key):
        return self.feedback[key]

    def __setitem__(self, key, value):
        self.feedback[key] = value

    # Feedback management interface

    @classmethod
    @transaction.atomic
    def create_new_version(cls, **kwargs):
        """
        Creates new feedback object and marks it as parent for all
        old feedbacks by same user to defined resource
        """
        kwargs = {k: v for k, v in kwargs.items() if v is not None}
        new = cls.objects.create(**kwargs)
        assert new.pk is not None, "New feedback doesn't have primary key"
        new.supersede_older()
        return new

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.__changed_fields = set()

    def __str__(self):
        return 'Feedback by {} to {} at {}'.format(self.student,
                                                   self.exercise_path,
                                                   self.timestamp)

    def save(self, update_fields=None, **kwargs):
        if update_fields is not None and self.__changed_fields:
            update_fields = tuple(set(update_fields) | self.__changed_fields)
        ret = super().save(update_fields=update_fields, **kwargs)
        self.__changed_fields = set()
        return ret

    def supersede_older(self):
        return self.older_versions.filter(
            superseded_by=None, ).update(superseded_by=self)
Ejemplo n.º 2
0
class FeedbackQuerySet(models.QuerySet):
    FILTER_FLAGS = Enum(
        ('NEWEST', 'n', _("Newest versions")),
        ('UNREAD', 'u', _("Unread")),
        ('READ', 'r', _("Read")),
        ('UNGRADED', 'q', _("Ungraded")),
        ('GRADED', 'g', _("Graded")),
        ('AUTO', 'a', _("Automatically graded")),
        ('MANUAL', 'm', _("Manually graded")),
        ('UNRESPONDED', 'i', _("Unresponded")),
        ('RESPONDED', 'h', _("Responded")),
        ('UPL_OK', 'o', _("Upload ok")),
        ('UPL_ERROR', 'e', _("Upload has error")),
    )

    FILTERS = {
        FILTER_FLAGS.NEWEST:
        Q(superseded_by=None),
        FILTER_FLAGS.UNREAD:
        Q(response_time=None) & Q(tags=None),
        FILTER_FLAGS.READ:
        ~(Q(response_time=None) & Q(tags=None)),
        FILTER_FLAGS.UNGRADED:
        Q(response_time=None),
        FILTER_FLAGS.GRADED:
        ~Q(response_time=None),
        FILTER_FLAGS.UNRESPONDED:
        Q(response_msg='') | Q(response_msg=None),
        FILTER_FLAGS.RESPONDED:
        ~Q(response_time=None) & ~Q(response_msg='') & ~Q(response_msg=None),
        FILTER_FLAGS.AUTO:
        ~Q(response_time=None) & Q(response_by=None),
        FILTER_FLAGS.MANUAL:
        ~Q(response_time=None) & ~Q(response_by=None),
        FILTER_FLAGS.UPL_OK:
        Q(_response_upl_code=200),
        FILTER_FLAGS.UPL_ERROR:
        ~Q(_response_upl_code=200) & ~Q(_response_upl_code=0),
    }

    def filter_flags(self, *flags):
        try:
            filters = [self.FILTERS[f] for f in flags]
        except KeyError as e:
            raise AttributeError("Invalid flag: {}".format(e))
        q = reduce(Q.__and__, filters)
        return self.filter(q)

    def filter_data(self, search):
        if '*' in search:
            search = search.replace('*', '%')
        else:
            search = ''.join(('%', '%'.join(shlex.split(search)), '%'))
        return self.extra(
            where=['form_data::text ilike %s'],
            params=[search],
        )

    def filter_missed_upload(self, time_gap_min=15):
        gap = timezone.now() - datetime.timedelta(minutes=time_gap_min)
        return self.filter(
            Q(_response_upl_code=0) & ~Q(response_time=None)
            & Q(response_time__lt=gap)).order_by('_response_upl_at')

    def filter_failed_upload(self, max_tries=10, time_gap_min=15):
        gap = timezone.now() - datetime.timedelta(minutes=time_gap_min)
        return self.filter(
            ~Q(_response_upl_code=200) & ~Q(_response_upl_code=0)
            & Q(_response_upl_attempt__lt=max_tries)
            & Q(_response_upl_at__lt=gap)).order_by('_response_upl_at')

    def feedback_exercises_for(self, course, student):
        q = self.values(
            'exercise_id',
            'path_key',
        ).filter(
            student=student,
            exercise__course=course,
        ).annotate(count=models.Count('form_data'), ).order_by(
            'exercise__course', 'exercise_id')
        return q

    def get_notresponded(self,
                         exercise_id=None,
                         course_id=None,
                         path_filter=None):
        qs = self.select_related('form', 'exercise').filter_flags(
            self.FILTER_FLAGS.NEWEST,
            self.FILTER_FLAGS.UNREAD,
        )
        if exercise_id is not None:
            qs = qs.filter(exercise__id=exercise_id)
        elif course_id is not None:
            qs = qs.filter(exercise__course__id=course_id)
            if path_filter:
                qs = qs.filter(path_key__startswith=path_filter)
        else:
            raise ValueError("exercise_id or course_id is required")
        return qs.order_by('timestamp')