-
Jan Maximilian Michal authored
* Reviewer feedback cannot be edited by tutors (despite assignments) * Tutors can always edit feedback for which they have an assignment * Reviewer is allowed to change anything anytime
Jan Maximilian Michal authored* Reviewer feedback cannot be edited by tutors (despite assignments) * Tutors can always edit feedback for which they have an assignment * Reviewer is allowed to change anything anytime
models.py 22.95 KiB
'''
Grady Model Description
-----------------------
See docstring of the individual models for information on the setup of the
database.
'''
import logging
import uuid
from collections import OrderedDict
from random import randrange
from typing import Dict
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser
from django.db import models, transaction
from django.db.models import (BooleanField, Case, Count, F, IntegerField, Q,
QuerySet, Sum, Value, When)
from django.db.models.functions import Coalesce
log = logging.getLogger(__name__)
def random_matrikel_no() -> str:
"""Use as a default value for student's matriculation number.
Returns:
str: an eight digit number
"""
return str(10_000_000 + randrange(90_000_000))
def get_annotated_tutor_list() -> QuerySet:
"""All tutor accounts are annotate with a field that includes the number of
feedback that tutor has collaborated in.
Returns:
TYPE: the annotated QuerySet
"""
return get_user_model().objects\
.filter(groups__name='Tutors')\
.annotate(Count('feedback_list'))\
.order_by('-feedback_list__count')
class ExamType(models.Model):
"""A model that contains information about the module a submission can
belong to. The information is not needed and is currently, just used to
detect if students already have enough points to pass an exam.
It is NOT intended to use this for including different exams regarding
submissions types.
Attributes
----------
module_reference : CharField
a unique reference that identifies a module within the university
pass_only : BooleanField
True if no grade is given
pass_score : PositiveIntegerField
minimum score for (just) passing
total_score : PositiveIntegerField
maximum score for the exam (currently never used anywhere)
"""
class Meta:
verbose_name = "ExamType"
verbose_name_plural = "ExamTypes"
def __str__(self) -> str:
return self.module_reference
exam_type_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
module_reference = models.CharField(max_length=50, unique=True)
total_score = models.PositiveIntegerField()
pass_score = models.PositiveIntegerField()
pass_only = models.BooleanField(default=False)
class SubmissionType(models.Model):
"""This model mostly holds meta information about the kind of task that was
presented to the student. It serves as a foreign key for the submissions
that are of this type. This model is currently NOT exposed directly in a
view.
Attributes
----------
description : TextField
The task description the student had to fulfill. The content may be
HTML formatted.
full_score : PositiveIntegerField
Maximum score one can get on that one
name : CharField
The original title of the exam. This is wildly used as an identifier by
the preprocessing scripts.
solution : TextField
A sample solution or a correction guideline
"""
submission_type_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
name = models.CharField(max_length=50, unique=True)
full_score = models.PositiveIntegerField(default=0)
description = models.TextField()
solution = models.TextField()
def __str__(self) -> str:
return self.name
class Meta:
verbose_name = "SubmissionType"
verbose_name_plural = "SubmissionType Set"
@classmethod
def get_annotated_feedback_count(cls) -> QuerySet:
""" Annotates submission lists with counts
The following fields are annotated:
* number of submissions per submission type
* count of received *accepted* feedback per submission type
* and finally the progress on each submission type as percentage
The QuerySet that is return is ordered by name lexicographically.
Returns:
The annotated QuerySet as described above
"""
return cls.objects\
.annotate( # to display only manual
feedback_count=Count(
Case(
When(
Q(submissions__feedback__isnull=False) &
Q(submissions__feedback__status=Feedback.ACCEPTED),
then=Value(1)), output_field=IntegerField(),
)
)
).annotate(
submission_count=Count('submissions')
).annotate(
percentage=(F('feedback_count') * 100 / F('submission_count'))
).order_by('name')
class UserAccount(AbstractUser):
"""
An abstract base class implementing a fully featured User model with
admin-compliant permissions.
Username and password are required. Other fields are optional.
"""
user_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
fullname = models.CharField('full name', max_length=70, blank=True)
is_admin = models.BooleanField(default=False)
STUDENT = 'Student'
TUTOR = 'Tutor'
REVIEWER = 'Reviewer'
ROLE_CHOICES = (
(STUDENT, 'student'),
(TUTOR, 'tutor'),
(REVIEWER, 'reviewer')
)
role = models.CharField(max_length=50, choices=ROLE_CHOICES)
def is_student(self):
return self.role == 'Student'
def is_tutor(self):
return self.role == 'Tutor'
def is_reviewer(self):
return self.role == 'Reviewer'
@classmethod
def get_students(cls):
return cls.objects.filter(role=cls.STUDENT)
@classmethod
def get_tutors(cls):
return cls.objects.filter(role=cls.TUTOR)
@classmethod
def get_reviewers(cls):
return cls.objects.filter(role=cls.REVIEWER)
class StudentInfo(models.Model):
"""
The StudentInfo model includes all information of a student, that we got
from the E-Learning output, along with some useful classmethods that
provide specially annotated QuerySets.
Information like email (if given), and the username are stored in the
associated user model.
Attributes:
exam (ForeignKey):
Which module the student wants to be graded in
has_logged_in (BooleanField):
Login is permitted once. If this is set the user can not log in.
matrikel_no (CharField):
The matriculation number of the student
"""
student_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
has_logged_in = models.BooleanField(default=False)
matrikel_no = models.CharField(unique=True,
max_length=8,
default=random_matrikel_no)
exam = models.ForeignKey('ExamType',
on_delete=models.SET_NULL,
related_name='students',
null=True)
user = models.OneToOneField(get_user_model(),
on_delete=models.CASCADE,
related_name='student')
def score_per_submission(self) -> Dict[str, int]:
""" TODO: get rid of it and use an annotation.
Returns:
TYPE: Description
"""
if self.submissions.all():
return OrderedDict({
s.type: s.feedback.score if hasattr(s, 'feedback') else 0
for s in self.submissions.all()
})
return OrderedDict({
t.name: 0 for t in SubmissionType.objects.all()
})
@classmethod
def get_annotated_score_submission_list(cls) -> QuerySet:
"""Can be used to quickly annotate a user with the necessary
information on the overall score of a student and if he does not need
any more correction.
A student is done if
* module type was pass_only and student has enough points
* every submission got accepted feedback
Returns
-------
QuerySet
the annotated QuerySet as described above.
"""
return cls.objects.annotate(
overall_score=Coalesce(Sum('submissions__feedback__score'),
Value(0)),
).annotate(
done=Case(
When(exam__pass_score__lt=F('overall_score'), then=Value(1)),
default=Value(0),
output_field=BooleanField()
)
).order_by('user__username')
def disable(self):
"""The student won't be able to login in anymore, but his current
session can be continued until s/he logs out.
"""
self.has_logged_in = True
self.save()
def __str__(self) -> str:
return self.user.username
class Meta:
verbose_name = "Student"
verbose_name_plural = "Student Set"
class Test(models.Model):
"""Tests contain information that has been unapproved by automated tests,
and directly belongs to a submission. Often certain Feedback was already
given by information provided by these tests.
Attributes
----------
annotation : TextField
All the output of the test (e.g. compiler output)
label : CharField
Indicates SUCCES or FAILURE
name : CharField
The name of the test that was performed
submission : ForeignKey
The submission the tests where unapproved on
"""
test_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
name = models.CharField(max_length=30)
label = models.CharField(max_length=50)
annotation = models.TextField()
submission = models.ForeignKey('submission',
related_name='tests',
on_delete=models.CASCADE,)
class Meta:
verbose_name = "Test"
verbose_name_plural = "Tests"
unique_together = (('submission', 'name'),)
def __str__(self) -> str:
return f'{self.name} {self.label}'
class Submission(models.Model):
"""The answer of a student to a specific question. Holds the answer and
very often serves as ForeignKey.
With the method assign_tutor feedback for a submission can be created and a
tutor will be assigned to this feedback permanently (unless deleted by a
reviewer or if it gets reassigned). There cannot be more than ONE feedback
per Submission.
Attributes
----------
seen_by_student : BooleanField
True if the student saw his accepted feedback.
student : ForgeignKey
The student how cause all of this
text : TextField
The code/text submitted by the student
type : OneToOneField
Relation to the type containing meta information
"""
submission_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
seen_by_student = models.BooleanField(default=False)
text = models.TextField(blank=True)
type = models.ForeignKey(
SubmissionType,
on_delete=models.PROTECT,
related_name='submissions')
student = models.ForeignKey(
StudentInfo,
on_delete=models.CASCADE,
related_name='submissions')
class Meta:
verbose_name = "Submission"
verbose_name_plural = "Submission Set"
unique_together = (('type', 'student'),)
ordering = ('type__name',)
def __str__(self) -> str:
return "Submission of type '{}' from Student '{}'".format(
self.type,
self.student
)
class Feedback(models.Model):
"""
Attributes
----------
score : PositiveIntegerField
A score that has been assigned to he submission. Is final if it was
accepted.
created : DateTimeField
When the feedback was initially created
of_submission : OneToOneField
The submission this feedback belongs to. It finally determines how many
points a student receives for his submission.
origin : IntegerField
Of whom was this feedback originally created. She below for the choices
"""
score = models.PositiveIntegerField(default=0)
created = models.DateTimeField(auto_now_add=True)
is_final = models.BooleanField(default=False)
of_submission = models.OneToOneField(
Submission,
on_delete=models.CASCADE,
related_name='feedback')
# how was this feedback created
(
WAS_EMPTY,
FAILED_UNIT_TESTS,
DID_NOT_COMPILE,
COULD_NOT_LINK,
MANUAL,
) = range(5)
ORIGIN = (
(WAS_EMPTY, 'was empty'),
(FAILED_UNIT_TESTS, 'passed unittests'),
(DID_NOT_COMPILE, 'did not compile'),
(COULD_NOT_LINK, 'could not link'),
(MANUAL, 'created by a human. yak!'),
)
origin = models.IntegerField(
choices=ORIGIN,
default=MANUAL,
)
class Meta:
verbose_name = "Feedback"
verbose_name_plural = "Feedback Set"
def __str__(self) -> str:
return 'Feedback for {}'.format(self.of_submission)
def is_full_score(self) -> bool:
return self.of_submission.type.full_score == self.score
def get_full_score(self) -> int:
return self.of_submission.type.full_score
class SubscriptionEnded(Exception):
pass
class SubscriptionTemporarilyEnded(Exception):
pass
class NotMoreThanTwoOpenAssignmentsAllowed(Exception):
pass
class SubmissionSubscription(models.Model):
RANDOM = 'random'
STUDENT_QUERY = 'student'
EXAM_TYPE_QUERY = 'exam'
SUBMISSION_TYPE_QUERY = 'submission_type'
type_query_mapper = {
RANDOM: '__any',
STUDENT_QUERY: 'student__pk',
EXAM_TYPE_QUERY: 'student__exam__pk',
SUBMISSION_TYPE_QUERY: 'type__pk',
}
QUERY_CHOICE = (
(RANDOM, 'Query for any submission'),
(STUDENT_QUERY, 'Query for submissions of student'),
(EXAM_TYPE_QUERY, 'Query for submissions of exam type'),
(SUBMISSION_TYPE_QUERY, 'Query for submissions of submissions_type'),
)
FEEDBACK_CREATION = 'feedback-creation'
FEEDBACK_VALIDATION = 'feedback-validation'
FEEDBACK_CONFLICT_RESOLUTION = 'feedback-conflict-resolution'
assignment_count_on_stage = {
FEEDBACK_CREATION: 0,
FEEDBACK_VALIDATION: 1,
FEEDBACK_CONFLICT_RESOLUTION: 2,
}
stages = (
(FEEDBACK_CREATION, 'No feedback was ever assigned'),
(FEEDBACK_VALIDATION, 'Feedback exists but is not validated'),
(FEEDBACK_CONFLICT_RESOLUTION, 'Previous correctors disagree'),
)
deactivated = models.BooleanField(default=False)
subscription_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
related_name='subscriptions')
query_key = models.UUIDField(null=True)
query_type = models.CharField(max_length=75,
choices=QUERY_CHOICE,
default=RANDOM)
feedback_stage = models.CharField(choices=stages,
max_length=40,
default=FEEDBACK_CREATION)
class Meta:
unique_together = ('owner',
'query_key',
'query_type',
'feedback_stage')
def _get_submission_base_query(self) -> QuerySet:
""" Get all submissions that are filtered by the query key and type,
e.g. all submissions of one student or submission type.
"""
if self.query_type == self.RANDOM:
return MetaSubmission.objects.all()
return MetaSubmission.objects.filter(
**{'submission__' + self.type_query_mapper[self.query_type]:
self.query_key})
def _get_submissions_that_do_not_have_final_feedback(self) -> QuerySet:
""" There are a number of conditions to check for each submission
1. The submission does not have final feedback
2. The submission was not shown to this user before
3. The submission is not currently assigned to somebody else
Returns:
QuerySet -- a list of all submissions ready for consumption
"""
return self._get_submission_base_query().select_for_update().exclude(
Q(has_final_feedback=True) |
Q(has_active_assignment=True) |
Q(feedback_authors=self.owner)
)
def _get_available_submissions_in_subscription_stage(self) -> QuerySet:
""" Another filter this time it returns all the submissions that
are valid in this stage. That means all previous stages have been
completed.
Raises:
SubscriptionEnded -- if the subscription will not yield
subscriptions in the future
SubscriptionTemporarilyEnded -- wait until new become available
"""
candidates = self._get_submissions_that_do_not_have_final_feedback()
if candidates.count() == 0:
raise SubscriptionEnded(
f'The task which user {self.owner} subscribed to is done')
done_assignments_count = self.assignment_count_on_stage[self.feedback_stage] # noqa
stage_candiates = candidates.filter(
done_assignments=done_assignments_count,
)
if stage_candiates.count() == 0:
raise SubscriptionTemporarilyEnded(
'Currently unavailable. Please check for more soon. '
'Submissions remaining: %s' % stage_candiates.count())
return stage_candiates
@transaction.atomic
def get_remaining_not_final(self) -> int:
return self._get_submissions_that_do_not_have_final_feedback().count()
@transaction.atomic
def get_available_in_stage(self) -> int:
try:
return self._get_available_submissions_in_subscription_stage().count() # noqa
except (SubscriptionTemporarilyEnded, SubscriptionEnded) as err:
return 0
@transaction.atomic
def get_or_create_work_assignment(self):
task = self._get_available_submissions_in_subscription_stage().first()
if self.assignments.filter(is_done=False).count() >= 2:
raise NotMoreThanTwoOpenAssignmentsAllowed(
'Not more than 2 active assignments allowed.')
log.info(f'{self.owner} is assigned to {task} ({self.feedback_stage})')
return TutorSubmissionAssignment.objects.create(
subscription=self,
submission=task.submission)
@transaction.atomic
def reserve_all_assignments_for_a_student(self):
assert self.query_type == self.STUDENT_QUERY
meta_submissions = self._get_submissions_that_do_not_have_final_feedback() # noqa
for meta in meta_submissions:
submission = meta.submission
if hasattr(submission, 'assignments'):
submission.assignments.filter(is_done=False).delete()
TutorSubmissionAssignment.objects.create(
subscription=self,
submission=submission
)
log.info(f'Loaded all subscriptions of student {self.query_key}')
@transaction.atomic
def delete(self):
self.assignments.filter(is_done=False).delete()
if self.assignments.count() == 0:
super().delete()
else:
self.deactivated = True
self.save()
class DeletionOfDoneAssignmentsNotPermitted(Exception):
pass
class MetaSubmission(models.Model):
submission = models.OneToOneField('submission',
related_name='meta',
on_delete=models.CASCADE)
done_assignments = models.PositiveIntegerField(default=0)
has_active_assignment = models.BooleanField(default=False)
has_feedback = models.BooleanField(default=False)
has_final_feedback = models.BooleanField(default=False)
feedback_authors = models.ManyToManyField(get_user_model())
def __str__(self):
return f''' Submission Meta of {self.submission}
done_assignments = {self.done_assignments}
has_active_assignment = {self.has_active_assignment}
has_feedback = {self.has_feedback}
has_final_feedback = {self.has_final_feedback}
feedback_authors = {self.feedback_authors.values_list('username',
flat=True)}
'''
class TutorSubmissionAssignment(models.Model):
assignment_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
submission = models.ForeignKey(Submission,
on_delete=models.CASCADE,
related_name='assignments')
subscription = models.ForeignKey(SubmissionSubscription,
on_delete=models.CASCADE,
related_name='assignments')
is_done = models.BooleanField(default=False)
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return (f'{self.subscription.owner} assigned to {self.submission}'
f' (done={self.is_done})')
def delete(self, *args, **kwargs):
if self.is_done:
raise DeletionOfDoneAssignmentsNotPermitted()
super().delete(*args, **kwargs)
class Meta:
unique_together = ('submission', 'subscription')
class FeedbackComment(models.Model):
""" This Class contains the Feedback for a specific line of a Submission"""
text = models.TextField()
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
visible_to_student = models.BooleanField(default=True)
of_line = models.PositiveIntegerField(default=0)
of_tutor = models.ForeignKey(
get_user_model(),
related_name="comment_list",
on_delete=models.PROTECT
)
of_feedback = models.ForeignKey(
Feedback,
related_name="feedback_lines",
on_delete=models.CASCADE,
null=True
)
class Meta:
verbose_name = "Feedback Comment"
verbose_name_plural = "Feedback Comments"
ordering = ('created',)
unique_together = ('of_line', 'of_tutor', 'of_feedback')
def __str__(self):
return 'Comment on line {} of tutor {}: "{}"'.format(self.of_line,
self.of_tutor,
self.text)