diff --git a/core/models.py b/core/models.py index 0dbf546b0688c01f0463d2c9baa483f75be3fddf..77f8fcc0c10bbe8f5461067345404b545b70f2be 100644 --- a/core/models.py +++ b/core/models.py @@ -424,6 +424,10 @@ class SubscriptionTemporarilyEnded(Exception): pass +class NotMoreThanTwoOpenAssignmentsAllowed(Exception): + pass + + class SubmissionSubscription(models.Model): RANDOM = 'random' @@ -502,7 +506,7 @@ class SubmissionSubscription(models.Model): ) ) - def _get_next_submission_in_subscription(self): + def _get_available_submissions_in_subscription_stage(self): candidates = self._get_submissions_that_do_not_have_final_feedback() if candidates.count() == 0: @@ -519,48 +523,36 @@ class SubmissionSubscription(models.Model): 'Currently unavailabe. Please check for more soon. ' 'Submissions remaining: %s' % stage_candiates.count()) - return stage_candiates.first() + return stage_candiates @transaction.atomic def get_or_create_work_assignment(self): - task = self._get_next_submission_in_subscription() + 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.') return TutorSubmissionAssignment.objects.get_or_create( subscription=self, submission=task)[0] + @transaction.atomic def reserve_all_assignments_for_a_student(self): assert self.query_type == self.STUDENT_QUERY - try: - while True: - self.get_or_create_work_assignment() - except SubscriptionEnded as err: - log.info(f'Loaded all subscriptions of student {self.query_key}') - - def _create_new_assignment_if_subscription_empty(self): - if self.assignments.filter(is_done=False).count() < 1: - self.get_or_create_work_assignment() - - def _eagerly_reserve_the_next_assignment(self): - if self.assignments.filter(is_done=False).count() < 2: - self.get_or_create_work_assignment() - - def get_oldest_unfinished_assignment(self): - self._create_new_assignment_if_subscription_empty() - return self.assignments \ - .filter(is_done=False) \ - .order_by('created') \ - .first() - - def get_youngest_unfinished_assignment(self): - self._create_new_assignment_if_subscription_empty() - self._eagerly_reserve_the_next_assignment() - return self.assignments \ - .filter(is_done=False) \ - .order_by('-created') \ - .first() + submissions = self._get_submissions_that_do_not_have_final_feedback() + for submission in submissions: + 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: diff --git a/core/serializers/feedback.py b/core/serializers/feedback.py index 95e0e0d92baf9f37d9b5a1de83638d06984a2489..b57b21a69c12a64f6ac44ef4a0e3e38d646023a6 100644 --- a/core/serializers/feedback.py +++ b/core/serializers/feedback.py @@ -1,14 +1,13 @@ import logging from collections import defaultdict -from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models.manager import Manager from rest_framework import serializers from rest_framework.utils import html from core import models -from core.models import Feedback, TutorSubmissionAssignment +from core.models import Feedback from util.factories import GradyUserFactory from .generic import DynamicFieldsModelSerializer @@ -82,16 +81,16 @@ class FeedbackCommentSerializer(serializers.ModelSerializer): class FeedbackSerializer(DynamicFieldsModelSerializer): - pk = serializers.ReadOnlyField(source='of_submission.pk') - assignment_pk = serializers.UUIDField(write_only=True) feedback_lines = FeedbackCommentSerializer(many=True, required=False) @transaction.atomic def create(self, validated_data) -> Feedback: - assignment = validated_data.pop('assignment_pk') + submission = validated_data.pop('of_submission') + assignment = submission.assignments.get( + subscription__owner=self.context['request'].user) feedback_lines = validated_data.pop('feedback_lines', []) - feedback = Feedback.objects.create(of_submission=assignment.submission, + feedback = Feedback.objects.create(of_submission=submission, **validated_data) for comment in feedback_lines: @@ -102,7 +101,7 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): ) assignment.set_done() - return Feedback.objects.get(of_submission=assignment.submission) + return Feedback.objects.get(of_submission=submission) @transaction.atomic def update(self, instance, validated_data): @@ -115,21 +114,26 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): return super().update(instance, validated_data) - def validate_assignment_pk(self, assignment_pk): - try: - assignment = TutorSubmissionAssignment.objects.get( - pk=assignment_pk) - except ObjectDoesNotExist as err: - raise serializers.ValidationError('No assignment for given id.') + def validate_of_submission(self, submission): + feedback = self.instance + if feedback is not None and feedback.submission is not submission: + raise serializers.ValidationError( + 'It is not allowed to update this field.') - return assignment + return submission def validate(self, data): - log.debug("Validate feedback data: %s", data) - score = data.get('score') - assignment = data.get('assignment_pk') + if self.instance: + score = data.get('score', self.instance.score) + submission = data.get('of_submission', self.instance.of_submission) + else: + try: + score = data.get('score') + submission = data.get('of_submission') + except KeyError as err: + raise serializers.ValidationError( + 'You need a score and a submission.') - submission = assignment.submission if not 0 <= score <= submission.type.full_score: raise serializers.ValidationError( f'Score has to be in range [0..{submission.type.full_score}].') @@ -157,4 +161,4 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): class Meta: model = Feedback - fields = ('pk', 'assignment_pk', 'is_final', 'score', 'feedback_lines') + fields = ('pk', 'of_submission', 'is_final', 'score', 'feedback_lines') diff --git a/core/serializers/subscription.py b/core/serializers/subscription.py index 87c9f6431cdd94766b51dd3672f94c015a7fe25f..ef9a63c808ebb7d13d25a76a64d394893c7f9e66 100644 --- a/core/serializers/subscription.py +++ b/core/serializers/subscription.py @@ -6,12 +6,19 @@ from core.serializers import DynamicFieldsModelSerializer, FeedbackSerializer class AssignmentSerializer(DynamicFieldsModelSerializer): - submission_pk = serializers.ReadOnlyField( - source='submission.pk') + submission_pk = serializers.ReadOnlyField(source='submission.pk') class Meta: model = TutorSubmissionAssignment - fields = ('pk', 'submission_pk', 'is_done',) + fields = ('pk', 'submission_pk', 'is_done', 'subscription',) + read_only_fields = ('is_done',) + extra_kwargs = { + 'subscription': {'write_only': True}, + } + + def create(self, validated_data): + subscription = validated_data.get('subscription') + return subscription.get_or_create_work_assignment() class SubmissionAssignmentSerializer(DynamicFieldsModelSerializer): @@ -64,4 +71,6 @@ class SubscriptionSerializer(DynamicFieldsModelSerializer): 'query_type', 'query_key', 'feedback_stage', + 'deactivated', 'assignments') + read_only_fields = ('deactivated',) diff --git a/core/tests/test_feedback.py b/core/tests/test_feedback.py index 2dd13b6451176328b4b18f75db548479ae174324..8397faf857442bedefecefef8d29b71355c06adf 100644 --- a/core/tests/test_feedback.py +++ b/core/tests/test_feedback.py @@ -123,7 +123,7 @@ class FeedbackCreateTestCase(APITestCase): data = { 'score': 10, 'is_final': False, - 'assignment_pk': self.assignment.pk + 'of_submission': self.assignment.submission.pk } self.assertEqual(Feedback.objects.count(), 0) @@ -135,7 +135,7 @@ class FeedbackCreateTestCase(APITestCase): data = { 'score': 101, 'is_final': False, - 'assignment_pk': self.assignment.pk + 'of_submission': self.assignment.submission.pk } self.assertEqual(Feedback.objects.count(), 0) response = self.client.post(self.url, data, format='json') @@ -146,7 +146,7 @@ class FeedbackCreateTestCase(APITestCase): data = { 'score': 100, 'is_final': True, - 'assignment_pk': self.assignment.assignment_id + 'of_submission': self.assignment.submission.pk } response = self.client.post(self.url, data, format='json') self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) @@ -156,7 +156,7 @@ class FeedbackCreateTestCase(APITestCase): data = { 'score': 50, 'is_final': False, - 'assignment_pk': self.assignment.assignment_id + 'of_submission': self.assignment.submission.pk } response = self.client.post(self.url, data, format='json') self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) @@ -166,7 +166,7 @@ class FeedbackCreateTestCase(APITestCase): data = { 'score': -1, 'is_final': False, - 'assignment_pk': self.assignment.pk + 'of_submission': self.assignment.submission.pk } self.assertEqual(Feedback.objects.count(), 0) response = self.client.post(self.url, data, format='json') @@ -177,7 +177,7 @@ class FeedbackCreateTestCase(APITestCase): data = { 'score': 5, 'is_final': False, - 'assignment_pk': self.assignment.pk, + 'of_submission': self.assignment.submission.pk, 'feedback_lines': { '4': { 'text': 'Why you no learn how to code, man?' @@ -192,7 +192,7 @@ class FeedbackCreateTestCase(APITestCase): data = { 'score': 0, 'is_final': False, - 'assignment_pk': self.assignment.pk, + 'of_submission': self.assignment.submission.pk, 'feedback_lines': { '4': { 'text': 'Nice meth!' @@ -208,7 +208,7 @@ class FeedbackCreateTestCase(APITestCase): data = { 'score': 0, 'is_final': False, - 'assignment_pk': self.assignment.pk, + 'of_submission': self.assignment.submission.pk, 'feedback_lines': { '3': { 'text': 'Nice meth!' @@ -226,7 +226,7 @@ class FeedbackCreateTestCase(APITestCase): def test_cannot_create_without_assignment(self): data = { 'score': 0, - 'assignment_pk': self.assignment.assignment_id, + 'of_submission': self.assignment.submission.pk, 'feedback_lines': { '2': { 'text': 'Well, at least you tried.' @@ -235,12 +235,12 @@ class FeedbackCreateTestCase(APITestCase): } self.assignment.delete() response = self.client.post(self.url, data, format='json') - self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) def test_cannot_create_with_someoneelses_assignment(self): data = { 'score': 0, - 'assignment_pk': self.assignment.assignment_id, + 'of_submission': self.assignment.submission.pk, 'feedback_lines': { '1': { 'text': 'Well, at least you tried.' @@ -256,7 +256,7 @@ class FeedbackCreateTestCase(APITestCase): data = { 'score': 0, 'is_final': False, - 'assignment_pk': self.assignment.pk, + 'of_submission': self.assignment.submission.pk, 'feedback_lines': { '1': { 'text': 'Nice meth!' @@ -325,13 +325,14 @@ class FeedbackPatchTestCase(APITestCase): data = { 'score': 35, 'is_final': False, - 'assignment_pk': self.assignment.assignment_id, + 'of_submission': self.assignment.submission.pk, 'feedback_lines': { '2': {'text': 'Very good.'}, } } response = self.client.post(self.burl, data, format='json') - self.feedback = Feedback.objects.get(of_submission=response.data['pk']) + self.feedback = Feedback.objects.get( + of_submission=response.data['of_submission']) self.url = f'{self.burl}{self.feedback.of_submission.submission_id}/' def test_can_patch_onto_the_own_feedback(self): diff --git a/core/tests/test_subscription_assignment_service.py b/core/tests/test_subscription_assignment_service.py index 3508ad3a2a74709eebab2a5675a550ed1579760e..247217068f1dc97c57d04cf7f9afeaebb65585d8 100644 --- a/core/tests/test_subscription_assignment_service.py +++ b/core/tests/test_subscription_assignment_service.py @@ -31,47 +31,42 @@ class SubmissionSubscriptionRandomTest(APITestCase): owner=self.t, query_type=SubmissionSubscription.RANDOM) def test_subscription_gets_an_assignment(self): - self.subscription._create_new_assignment_if_subscription_empty() + self.subscription.get_or_create_work_assignment() self.assertEqual(1, self.subscription.assignments.count()) def test_first_work_assignment_was_created_unfinished(self): - self.subscription._create_new_assignment_if_subscription_empty() + self.subscription.get_or_create_work_assignment() self.assertFalse(self.subscription.assignments.first().is_done) def test_subscription_raises_error_when_depleted(self): self.submission_01.delete() self.submission_02.delete() try: - self.subscription._create_new_assignment_if_subscription_empty() + self.subscription.get_or_create_work_assignment() except SubscriptionEnded as err: self.assertFalse(False) else: self.assertTrue(False) def test_can_prefetch(self): - self.subscription._create_new_assignment_if_subscription_empty() - self.subscription._eagerly_reserve_the_next_assignment() + self.subscription.get_or_create_work_assignment() + self.subscription.get_or_create_work_assignment() self.assertEqual(2, self.subscription.assignments.count()) - def test_oldest_assignment_is_current(self): - assignment = self.subscription.get_oldest_unfinished_assignment() - self.assertEqual(assignment, - self.subscription.get_oldest_unfinished_assignment()) - def test_new_subscription_is_temporarily_unavailabe(self): validation = SubmissionSubscription.objects.create( owner=self.t, query_type=SubmissionSubscription.RANDOM, feedback_stage=SubmissionSubscription.FEEDBACK_VALIDATION) try: - validation._create_new_assignment_if_subscription_empty() + validation.get_or_create_work_assignment() except SubscriptionTemporarilyEnded as err: self.assertTrue(True) else: self.assertTrue(False) def test_delete_with_done_assignments_subscription_remains(self): - first = self.subscription.get_oldest_unfinished_assignment() - self.subscription.get_youngest_unfinished_assignment() + first = self.subscription.get_or_create_work_assignment() + self.subscription.get_or_create_work_assignment() self.assertEqual(2, self.subscription.assignments.count()) first.is_done = True @@ -84,7 +79,7 @@ class SubmissionSubscriptionRandomTest(APITestCase): self.assertEqual(0, models.SubmissionSubscription.objects.count()) def test_assignment_delete_of_done_not_permitted(self): - first = self.subscription.get_oldest_unfinished_assignment() + first = self.subscription.get_or_create_work_assignment() first.is_done = True first.save() @@ -92,7 +87,7 @@ class SubmissionSubscriptionRandomTest(APITestCase): first.delete) def test_assignment_delete_undone_permitted(self): - first = self.subscription.get_oldest_unfinished_assignment() + first = self.subscription.get_or_create_work_assignment() first.delete() self.assertEqual(0, self.subscription.assignments.count()) @@ -196,24 +191,31 @@ class TestApiEndpoints(APITestCase): client = APIClient() client.force_authenticate(user=self.data['tutors'][0]) - response_subs = client.post( + response_subscription_create = client.post( '/api/subscription/', {'query_type': 'random'}) - subscription_id = response_subs.data['pk'] + subscription_pk = response_subscription_create.data['pk'] + + subscription_pk = response_subscription_create.data['pk'] + response_assignment = client.post( + f'/api/assignment/', { + 'subscription': subscription_pk + }) - response_assignment = client.get( - f'/api/subscription/{subscription_id}/assignments/current/') assignment_pk = response_assignment.data['pk'] response_subscription = client.get( - f'/api/subscription/{subscription_id}/') + f'/api/subscription/{subscription_pk}/') self.assertEqual(1, len(response_subscription.data['assignments'])) self.assertEqual(response_assignment.data['pk'], response_subscription.data['assignments'][0]['pk']) - response_next = client.get( - f'/api/subscription/{subscription_id}/assignments/next/') + subscription_pk = response_subscription.data['pk'] + response_next = client.post( + f'/api/assignment/', { + 'subscription': subscription_pk + }) response_detail_subs = \ - client.get(f'/api/subscription/{subscription_id}/') + client.get(f'/api/subscription/{subscription_pk}/') self.assertEqual(2, len(response_detail_subs.data['assignments'])) self.assertNotEqual(assignment_pk, response_next.data['pk']) @@ -245,15 +247,17 @@ class TestApiEndpoints(APITestCase): 'feedback_stage': 'feedback-creation' }) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) - response = client.get( - f'/api/subscription/{response.data["pk"]}/assignments/current/') - assignment_pk = response.data['pk'] + subscription_pk = response.data['pk'] + response = client.post( + f'/api/assignment/', { + 'subscription': subscription_pk + }) response = client.post( f'/api/feedback/', { "score": 23, - "assignment_pk": assignment_pk, + "of_submission": response.data['submission']['pk'], "feedback_lines": { 2: {"text": "< some string >"}, 3: {"text": "< some string >"} @@ -261,7 +265,7 @@ class TestApiEndpoints(APITestCase): } ) print(response, response.data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) # some other tutor reviews it client.force_authenticate(user=self.data['tutors'][1]) @@ -272,13 +276,15 @@ class TestApiEndpoints(APITestCase): 'feedback_stage': 'feedback-validation' }) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) - subscription_id = response.data['pk'] - response = client.get( - f'/api/subscription/{subscription_id}/assignments/current/') + subscription_pk = response.data['pk'] + response = client.post( + f'/api/assignment/', { + 'subscription': subscription_pk + }) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) submission_id_in_database = models.Feedback.objects.filter( is_final=False).first().of_submission.submission_id submission_id_in_response = \ diff --git a/core/views/feedback.py b/core/views/feedback.py index 4675e8f5faaa3157fc9b361fa451218474b0682e..f0af99b1fa828822e3c77f8ff3ba87e49b9221b1 100644 --- a/core/views/feedback.py +++ b/core/views/feedback.py @@ -24,9 +24,19 @@ class FeedbackApiView( user_is_tutor = self.request.user.role == models.UserAccount.TUTOR return feedback_is_final and user_is_tutor + def _get_implicit_assignment_for_user(self, submission): + return models.TutorSubmissionAssignment.objects.get( + subscription__owner=self.request.user, + submission=submission + ) + def _request_user_does_not_own_assignment(self, serializer): - assignment = serializer.validated_data['assignment_pk'] - return assignment.subscription.owner != self.request.user + try: + submission = serializer.validated_data['of_submission'] + assignment = self._get_implicit_assignment_for_user(submission) + return assignment.subscription.owner != self.request.user + except models.TutorSubmissionAssignment.DoesNotExist as err: + return True def _tutor_attempts_to_set_first_feedback_final(self, serializer): is_final_set = serializer.validated_data.get('is_final', False) @@ -34,17 +44,19 @@ class FeedbackApiView( return is_final_set and user_is_tutor def _tutor_is_allowed_to_change_own_feedback(self, serializer): - assignment = serializer.validated_data['assignment_pk'] - youngest = models.TutorSubmissionAssignment.objects\ - .filter(submission=assignment.submission)\ - .order_by('-created')\ + submission = self.get_object().of_submission + assignment = self._get_implicit_assignment_for_user(submission) + youngest = models.TutorSubmissionAssignment.objects \ + .filter(submission=submission) \ + .order_by('-created') \ .first() return assignment == youngest def _tutor_attempts_to_patch_first_feedback_final(self, serializer): is_final_set = serializer.validated_data.get('is_final', False) - assignment = serializer.validated_data.get('assignment_pk') + submission = self.get_object().of_submission + assignment = self._get_implicit_assignment_for_user(submission) in_creation = assignment.subscription.feedback_stage == models.SubmissionSubscription.FEEDBACK_CREATION # noqa return is_final_set and in_creation @@ -54,12 +66,12 @@ class FeedbackApiView( if self._tutor_attempts_to_set_first_feedback_final(serializer): return Response( - {'It is not allowed to create feedback final for tutors'}, + {'For tutors it is not allowed to create feedback final.'}, status=status.HTTP_403_FORBIDDEN) if self._request_user_does_not_own_assignment(serializer): return Response( - {'This user has not permission to create this feedback'}, + {'This user has no permission to create this feedback'}, status=status.HTTP_403_FORBIDDEN) self.perform_create(serializer) @@ -68,22 +80,13 @@ class FeedbackApiView( def partial_update(self, request, **kwargs): feedback = self.get_object() - assignment = models.TutorSubmissionAssignment.objects.get( - subscription__owner=request.user, - submission=feedback.of_submission - ) - serializer = self.get_serializer( - feedback, - data={'assignment_pk': assignment.pk, - 'score': feedback.score, - **request.data}, - partial=True) + serializer = self.get_serializer(feedback, data=request.data, + partial=True) serializer.is_valid(raise_exception=True) - if self._tutor_attempts_to_change_final_feedback(serializer): return Response( - {'Cannot set the first feedback final unless user reviewer'}, + {"Changing final feedback is not allowed"}, status=status.HTTP_403_FORBIDDEN) if self._tutor_attempts_to_patch_first_feedback_final(serializer): diff --git a/core/views/subscription.py b/core/views/subscription.py index 36160ae53bcd112d3de3ab01b55dd42b43dbb3ff..66908d69c963636f17f4adec9342888e274db88f 100644 --- a/core/views/subscription.py +++ b/core/views/subscription.py @@ -2,13 +2,12 @@ import logging from django.core.exceptions import ObjectDoesNotExist from rest_framework import mixins, status, viewsets -from rest_framework.decorators import detail_route from rest_framework.response import Response from core import models, permissions, serializers from core.models import TutorSubmissionAssignment from core.permissions import IsTutorOrReviewer -from core.serializers import AssignmentSerializer +from core.serializers import AssignmentDetailSerializer, AssignmentSerializer log = logging.getLogger(__name__) @@ -22,35 +21,9 @@ class SubscriptionApiViewSet( permission_classes = (permissions.IsTutorOrReviewer,) serializer_class = serializers.SubscriptionSerializer - def fetch_assignment(self, request, assignment_fetcher): - try: - assignment = assignment_fetcher() - except models.SubscriptionEnded as err: - return Response({'Error': str(err)}, - status=status.HTTP_410_GONE) - except models.SubscriptionTemporarilyEnded as err: - return Response({'Error': str(err)}, - status=status.HTTP_404_NOT_FOUND) - serializer = serializers.AssignmentDetailSerializer(assignment) - return Response(serializer.data) - - @detail_route(methods=['get'], url_path='assignments/current') - def current_assignment(self, request, pk=None): - subscription = self.get_object() - - return self.fetch_assignment( - request, subscription.get_oldest_unfinished_assignment) - - @detail_route(methods=['get'], url_path='assignments/next') - def next_assignment(self, request, pk=None): - subscription = self.get_object() - - return self.fetch_assignment( - request, subscription.get_youngest_unfinished_assignment) - def get_queryset(self): return models.SubmissionSubscription.objects.filter( - owner=self.request.user, deactivated=False) + owner=self.request.user) def _get_subscription_if_type_exists(self, data): try: @@ -96,6 +69,21 @@ class AssignmentApiViewSet( queryset = TutorSubmissionAssignment.objects.all() serializer_class = AssignmentSerializer + def _fetch_assignment(self, serializer): + try: + serializer.save() + except models.SubscriptionEnded as err: + return Response({'Error': str(err)}, + status=status.HTTP_410_GONE) + except models.SubscriptionTemporarilyEnded as err: + return Response({'Error': str(err)}, + status=status.HTTP_404_NOT_FOUND) + except models.NotMoreThanTwoOpenAssignmentsAllowed as err: + return Response({'Error': str(err)}, + status=status.HTTP_403_FORBIDDEN) + serializer = AssignmentDetailSerializer(serializer.instance) + return Response(serializer.data, status=status.HTTP_201_CREATED) + def get_queryset(self): """ Get only assignments of that user """ return TutorSubmissionAssignment.objects.filter( @@ -110,3 +98,8 @@ class AssignmentApiViewSet( instance.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._fetch_assignment(serializer)