From 4185241590b00f404f4e8e5bc625bd03814f1b89 Mon Sep 17 00:00:00 2001 From: janmax <j.michal@stud.uni-goettingen.de> Date: Sun, 7 Jan 2018 11:33:34 +0100 Subject: [PATCH] Changed student query_key to a UUID. Closes #87 * Added stages to the subscription mechanism, meaning that a subscription is distingushed between feedback-creation, -validation and -conflict resolution * Minor refactoring to the Feedback ViewSet and serializer * Added a larger integration test --- core/migrations/0001_initial.py | 17 ++-- core/models.py | 76 ++++++++++++----- core/serializers.py | 25 +++++- .../test_subscription_assignment_service.py | 82 +++++++++++++++++-- core/views.py | 17 +++- 5 files changed, 174 insertions(+), 43 deletions(-) diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 9102abb2..014e8786 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,15 +1,13 @@ -# Generated by Django 2.0.1 on 2018-01-07 14:37 - -import uuid +# Generated by Django 2.0.1 on 2018-01-10 10:46 +import core.models +from django.conf import settings import django.contrib.auth.models import django.contrib.auth.validators +from django.db import migrations, models import django.db.models.deletion import django.utils.timezone -from django.conf import settings -from django.db import migrations, models - -import core.models +import uuid class Migration(migrations.Migration): @@ -113,13 +111,14 @@ class Migration(migrations.Migration): ('subscription_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('query_key', models.CharField(blank=True, max_length=75)), ('query_type', models.CharField(choices=[('random', 'Query for any submission'), ('student', 'Query for submissions of student'), ('exam', 'Query for submissions of exam type'), ('submission_type', 'Query for submissions of submissions_type')], default='random', max_length=75)), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='susbscriptions', to=settings.AUTH_USER_MODEL)), + ('feedback_stage', models.CharField(choices=[('feedback-creation', 'No feedback was ever assigned'), ('feedback-validation', 'Feedback exists but is not validated'), ('feedback-conflict-resolution', 'Previous correctors disagree')], default='feedback-creation', max_length=40)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL)), ], ), migrations.CreateModel( name='StudentInfo', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('student_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('has_logged_in', models.BooleanField(default=False)), ('matrikel_no', models.CharField(default=core.models.random_matrikel_no, max_length=8, unique=True)), ('exam', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='core.ExamType')), diff --git a/core/models.py b/core/models.py index 47da2b27..8bbce3f4 100644 --- a/core/models.py +++ b/core/models.py @@ -205,6 +205,9 @@ class StudentInfo(models.Model): 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, @@ -446,7 +449,7 @@ class GeneralTaskSubscription(models.Model): type_query_mapper = { RANDOM: '__any', - STUDENT_QUERY: 'student__user__username', + STUDENT_QUERY: 'student__student_id', EXAM_TYPE_QUERY: 'student__examtype__module_reference', SUBMISSION_TYPE_QUERY: 'type__title', } @@ -458,16 +461,35 @@ class GeneralTaskSubscription(models.Model): (SUBMISSION_TYPE_QUERY, 'Query for submissions of submissions_type'), ) + FEEDBACK_CREATION = 'feedback-creation' + FEEDBACK_VALIDATION = 'feedback-validation' + FEEDBACK_CONFLICT_RESOLUTION = 'feedback-conflict-resolution' + + type_to_assignment_count_map = { + 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'), + ) + subscription_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, - related_name='susbscriptions') + related_name='subscriptions') query_key = models.CharField(max_length=75, blank=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') @@ -479,6 +501,24 @@ class GeneralTaskSubscription(models.Model): return Submission.objects.filter( **{self.type_query_mapper[self.query_type]: self.query_key}) + def _find_submissions_based_on_completed_assignments( + self, done_assignments_count): + base = self._get_submission_base_query().filter( + Q(feedback__isnull=False), + Q(feedback__is_final=False), + ~Q(feedback__of_tutor=self.owner) + ).annotate( # to display only manual + done_assignments_count=Count( + Case(When(assignments__is_done=True, then=Value(1)), + output_field=IntegerField(), + ) + ) + ).filter(done_assignments_count=self.type_to_assignment_count_map[ + done_assignments_count]) + + log.debug('base %s', base) + return base + def _find_unassigned_non_final_submissions(self): unassigned_non_final_submissions = \ self._get_submission_base_query().filter( @@ -492,31 +532,25 @@ class GeneralTaskSubscription(models.Model): return unassigned_non_final_submissions def _find_unassigned_unapproved_non_final_submissions(self): - unapproved_not_final_submissions = \ - self._get_submission_base_query().filter( - Q(feedback__isnull=False), - Q(feedback__is_final=False), - ~Q(feedback__of_tutor=self.owner), - # TODO: prevent reassigning to the same tutor - ) - - log.debug('unapproved not final submissions %s', - unapproved_not_final_submissions) + return self._find_submissions_based_on_completed_assignments( + done_assignments_count=self.FEEDBACK_VALIDATION) - return unapproved_not_final_submissions + def _find_submissions_with_confilcting_feedback(self): + return self._find_submissions_based_on_completed_assignments( + done_assignments_count=self.FEEDBACK_CONFLICT_RESOLUTION) def _get_next_assignment_in_subscription(self): - assignment_priority = ( + candidates = ( self._find_unassigned_non_final_submissions, - self._find_unassigned_unapproved_non_final_submissions - ) + self._find_unassigned_unapproved_non_final_submissions, + self._find_submissions_with_confilcting_feedback + )[self.type_to_assignment_count_map[self.feedback_stage]]() - lazy_queries = (query_set() for query_set in assignment_priority) - for query in (q for q in lazy_queries if len(q) > 0): - return query.first() + if candidates.count() == 0: + raise SubscriptionEnded( + f'The task which user {self.owner} subscribed to is done') - raise SubscriptionEnded( - f'The task which user {self.owner} subscribed to is done') + return candidates.first() @transaction.atomic def get_or_create_work_assignment(self): diff --git a/core/serializers.py b/core/serializers.py index 778c1b90..5f9d7391 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -15,6 +15,14 @@ log = logging.getLogger(__name__) user_factory = GradyUserFactory() +class NoValidAssignmentForFeedbackCreation(Exception): + pass + + +class CannotSetFirstFeedbackFinal(Exception): + pass + + class DynamicFieldsModelSerializer(DynamicFieldsMixin, serializers.ModelSerializer): pass @@ -29,6 +37,7 @@ class ExamSerializer(DynamicFieldsModelSerializer): class FeedbackForSubmissionLineSerializer(serializers.BaseSerializer): + def to_representation(self, obj): return {feedback.of_line: FeedbackCommentSerializer( @@ -44,7 +53,7 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): feedbacklines = FeedbackForSubmissionLineSerializer( required=False ) - isFinal = serializers.BooleanField(source="is_final") + isFinal = serializers.BooleanField(source="is_final", required=False) ofSubmission = serializers.PrimaryKeyRelatedField( source='of_submission', required=False, @@ -76,6 +85,7 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): log.debug(data) assignment_id = data.pop('assignment_id') score = data.get('score') + is_final = data.get('is_final', False) try: assignment = TutorSubmissionAssignment.objects.get( @@ -83,6 +93,16 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): except ObjectDoesNotExist as err: raise serializers.ValidationError('No assignment for given id.') + requesting_tutor = self.context['request'].user + if assignment.subscription.owner != requesting_tutor: + raise NoValidAssignmentForFeedbackCreation() + + http_method = self.context['request'].method + if (is_final and + http_method == 'POST' and + requesting_tutor.role == models.UserAccount.TUTOR): + raise CannotSetFirstFeedbackFinal() + submission = assignment.submission if not 0 <= score <= submission.type.full_score: raise serializers.ValidationError( @@ -251,4 +271,5 @@ class SubscriptionSerializer(DynamicFieldsModelSerializer): 'owner', 'query_type', 'query_key', - 'assignments') + 'assignments', + 'feedback_stage') diff --git a/core/tests/test_subscription_assignment_service.py b/core/tests/test_subscription_assignment_service.py index ebdcd33d..1302fd52 100644 --- a/core/tests/test_subscription_assignment_service.py +++ b/core/tests/test_subscription_assignment_service.py @@ -1,6 +1,7 @@ from rest_framework import status from rest_framework.test import APIClient, APITestCase +from core import models from core.models import (GeneralTaskSubscription, Submission, SubmissionType, SubscriptionEnded) from util.factories import GradyUserFactory, make_test_data @@ -91,6 +92,9 @@ class TestApiEndpoints(APITestCase): {'username': 'tutor01'}, {'username': 'tutor02'} ], + 'reviewers': [ + {'username': 'reviewer'} + ], 'submissions': [ { 'text': 'function blabl\n' @@ -145,7 +149,7 @@ class TestApiEndpoints(APITestCase): self.assertEqual('tutor01', response.data['owner']) - def test_subscription_has_next_assignment(self): + def test_subscription_has_next_and_current_assignment(self): client = APIClient() client.force_authenticate(user=self.data['tutors'][0]) @@ -154,22 +158,86 @@ class TestApiEndpoints(APITestCase): subscription_id = response_subs.data['subscription_id'] assignment_id = response_subs.data['assignments'][0]['assignment_id'] - response_current = client.get( + response = client.get( f'/api/subscription/{subscription_id}/assignments/current/') self.assertEqual(1, len(response_subs.data['assignments'])) self.assertEqual(assignment_id, - response_current.data['assignment_id']) + response.data['assignment_id']) + + response_next = client.get( + f'/api/subscription/{subscription_id}/assignments/next/') + response_detail_subs = \ + client.get(f'/api/subscription/{subscription_id}/') + + self.assertEqual(2, len(response_detail_subs.data['assignments'])) + self.assertNotEqual(assignment_id, response_next.data['assignment_id']) def test_subscription_can_assign_to_student(self): client = APIClient() - client.force_authenticate(user=self.data['tutors'][0]) + client.force_authenticate(user=self.data['reviewers'][0]) - response_subs = client.post( + student01 = self.data['students'][0] + + response = client.post( '/api/subscription/', { 'query_type': 'student', - 'query_key': 'student01' + 'query_key': student01.student.student_id, + 'stage': 'feedback-creation' }) - assignments = response_subs.data['assignments'] + assignments = response.data['assignments'] self.assertEqual(2, len(assignments)) + + def test_all_stages_of_the_subscription(self): + client = APIClient() + client.force_authenticate(user=self.data['tutors'][0]) + + # The tutor corrects something + response = client.post( + '/api/subscription/', { + 'query_type': 'random', + 'feedback_stage': 'feedback-creation' + }) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + assignment_id = response.data['assignments'][0]['assignment_id'] + response = client.post( + f'/api/feedback/', { + "score": 23, + "assignment_id": assignment_id, + "feedbacklines": { + 2: {"text": "< some string >"}, + 3: {"text": "< some string >"} + } + } + ) + print(response, response.data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # some other tutor reviews it + client.force_authenticate(user=self.data['tutors'][1]) + + response = client.post( + '/api/subscription/', { + 'query_type': 'random', + 'feedback_stage': 'feedback-validation' + }) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + subscription_id = response.data['subscription_id'] + response = client.get( + f'/api/subscription/{subscription_id}/assignments/current/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + submissioin_id_in_database = models.Feedback.objects.filter( + is_final=False).first().of_submission.submission_id + submissioin_id_in_response = \ + response.data['submission']['submission_id'] + + self.assertEqual( + str(submissioin_id_in_database), + submissioin_id_in_response) diff --git a/core/views.py b/core/views.py index 886d41c5..165498d7 100644 --- a/core/views.py +++ b/core/views.py @@ -6,7 +6,7 @@ from rest_framework import generics, mixins, status, viewsets from rest_framework.decorators import api_view, detail_route from rest_framework.response import Response -from core import models +from core import models, serializers from core.models import (ExamType, Feedback, GeneralTaskSubscription, StudentInfo, SubmissionType, TutorSubmissionAssignment) @@ -67,9 +67,18 @@ class FeedbackApiView( lookup_url_kwarg = 'submission_id' def create(self, request, *args, **kwargs): - serializer = self.get_serializer( - data={**request.data, 'of_tutor': request.user}) - serializer.is_valid(raise_exception=True) + serializer = self.get_serializer(data=request.data) + + try: + serializer.is_valid(raise_exception=True) + except serializers.NoValidAssignmentForFeedbackCreation: + return Response( + {'This user has not permission to create this feedback'}, + status=status.HTTP_403_FORBIDDEN) + except serializers.CannotSetFirstFeedbackFinal: + return Response( + {'Cannot set the first feedback final unless user reviewer'}, + status=status.HTTP_403_FORBIDDEN) self.perform_create(serializer) headers = self.get_success_headers(serializer.data) -- GitLab