From b3d216eadeb787b9a3e5c557b4b18e1a1cd35a4a Mon Sep 17 00:00:00 2001
From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de>
Date: Tue, 6 Aug 2019 15:43:27 +0200
Subject: [PATCH 1/8] Closes #155

---
 core/models/assignment.py                     | 18 +++++
 core/models/submission.py                     |  2 +
 core/serializers/feedback.py                  |  3 +-
 core/signals.py                               | 10 ---
 core/tests/test_custom_subscription_filter.py |  5 ++
 core/tests/test_feedback.py                   | 35 +++++-----
 .../test_subscription_assignment_service.py   |  7 +-
 core/tests/test_tutor_api_endpoints.py        |  1 +
 core/urls.py                                  |  1 +
 core/views/feedback.py                        | 68 +++++--------------
 core/views/subscription.py                    | 43 ++++++++++--
 core/views/util.py                            | 33 +++++++++
 frontend/src/api.ts                           | 18 +++--
 .../src/store/modules/submission-notes.ts     | 17 +++--
 functional_tests/test_export_modal.py         |  2 +-
 15 files changed, 160 insertions(+), 103 deletions(-)
 create mode 100644 core/views/util.py

diff --git a/core/models/assignment.py b/core/models/assignment.py
index fc8cfa17..77967461 100644
--- a/core/models/assignment.py
+++ b/core/models/assignment.py
@@ -14,6 +14,10 @@ class DeletionOfDoneAssignmentsNotPermitted(Exception):
     pass
 
 
+class CanOnlyCallFinishOnUnfinishedAssignments(Exception):
+    pass
+
+
 class TutorSubmissionAssignment(models.Model):
 
     assignment_id = models.UUIDField(primary_key=True,
@@ -32,6 +36,20 @@ class TutorSubmissionAssignment(models.Model):
         return (f'{self.subscription.owner} assigned to {self.submission}'
                 f' (done={self.is_done})')
 
+    def finish(self):
+        self.refresh_from_db()
+        if self.is_done:
+            raise CanOnlyCallFinishOnUnfinishedAssignments()
+
+        meta = self.submission.meta
+        # TODO add reviewer final
+        meta.feedback_authors.add(self.subscription.owner)
+        meta.done_assignments += 1
+        meta.has_active_assignment = False
+        self.is_done = True
+        self.save()
+        meta.save()
+
     def delete(self, *args, **kwargs):
         if self.is_done:
             raise DeletionOfDoneAssignmentsNotPermitted()
diff --git a/core/models/submission.py b/core/models/submission.py
index 3687fb67..f968a657 100644
--- a/core/models/submission.py
+++ b/core/models/submission.py
@@ -63,7 +63,9 @@ class MetaSubmission(models.Model):
     done_assignments = models.PositiveIntegerField(default=0)
     has_active_assignment = models.BooleanField(default=False)
 
+    # Managed by signal!
     has_feedback = models.BooleanField(default=False)
+    # Managed by signal!
     has_final_feedback = models.BooleanField(default=False)
 
     feedback_authors = models.ManyToManyField(get_user_model())
diff --git a/core/serializers/feedback.py b/core/serializers/feedback.py
index fbbc9b32..19a430cc 100644
--- a/core/serializers/feedback.py
+++ b/core/serializers/feedback.py
@@ -205,8 +205,7 @@ class FeedbackSerializer(DynamicFieldsModelSerializer):
             raise serializers.ValidationError(
                 'Sorry, you have to explain why this does not get full score')
 
-        http_method = self.context['request'].method
-        if hasattr(submission, 'feedback') and http_method == 'POST':
+        if hasattr(submission, 'feedback') and not self.instance:
             raise serializers.ValidationError(
                 'Feedback for this submission already exists')
 
diff --git a/core/signals.py b/core/signals.py
index 95cdcc1a..110a15e8 100644
--- a/core/signals.py
+++ b/core/signals.py
@@ -50,16 +50,6 @@ def update_after_feedback_save(sender, instance, created, **kwargs):
     meta = instance.of_submission.meta
     meta.has_feedback = True
     meta.has_final_feedback = instance.is_final
-
-    undone_assignment = meta.submission.assignments.filter(is_done=False)
-    assert undone_assignment.count() <= 1
-    if undone_assignment.count() > 0:
-        log.debug('SIGNAL -- Completed: %s' % undone_assignment.first())
-        meta.feedback_authors.add(undone_assignment.first().subscription.owner)
-        meta.done_assignments += 1
-        meta.has_active_assignment = False
-        undone_assignment.update(is_done=True)
-
     meta.save()
 
 
diff --git a/core/tests/test_custom_subscription_filter.py b/core/tests/test_custom_subscription_filter.py
index 64525f58..c6ac1767 100644
--- a/core/tests/test_custom_subscription_filter.py
+++ b/core/tests/test_custom_subscription_filter.py
@@ -140,6 +140,7 @@ class StopOnPass(APITestCase):
         self.assertEqual(35, self.data['students'][0].student.total_score)
         self.assertTrue(self.data['students'][0].student.passes_exam)
 
+    # TODO why is this code commented?!?
     # def test_submissions_left_after_not_pass_only_student_passed_exam(self):
     #     Feedback.objects.create(
     #         of_submission=self.data['submissions'][3], score=20)
@@ -164,8 +165,10 @@ class StopOnPass(APITestCase):
         # signals recognize the open assignments
         Feedback.objects.create(
             of_submission=a1.submission, score=20)
+        a1.finish()
         Feedback.objects.create(
             of_submission=a2.submission, score=15)
+        a2.finish()
 
         subscription_other_tutor = SubmissionSubscription.objects.create(
             owner=self.tutor02,
@@ -188,8 +191,10 @@ class StopOnPass(APITestCase):
         # signals recognize the open assignments
         Feedback.objects.create(
             of_submission=a1.submission, score=20)
+        a1.finish()
         Feedback.objects.create(
             of_submission=a2.submission, score=15)
+        a2.finish()
 
         subscription_other_tutor = SubmissionSubscription.objects.create(
             owner=self.tutor02,
diff --git a/core/tests/test_feedback.py b/core/tests/test_feedback.py
index dea21a72..7217cc86 100644
--- a/core/tests/test_feedback.py
+++ b/core/tests/test_feedback.py
@@ -93,7 +93,7 @@ class FeedbackCreateTestCase(APITestCase):
 
     @classmethod
     def setUpTestData(cls):
-        cls.url = '/api/feedback/'
+        cls.url = lambda self: f'/api/assignment/{self.assignment.pk}/finish/'
         cls.user_factory = GradyUserFactory()
         cls.tutor = cls.user_factory.make_tutor(password='p')
         cls.exam = make_exams(exams=[{
@@ -138,7 +138,7 @@ class FeedbackCreateTestCase(APITestCase):
 
         }
         self.assertEqual(Feedback.objects.count(), 0)
-        response = self.client.post(self.url, data, format='json')
+        response = self.client.post(self.url(), data, format='json')
         self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code)
         self.assertEqual(Feedback.objects.count(), 0)
 
@@ -149,7 +149,7 @@ class FeedbackCreateTestCase(APITestCase):
             'of_submission': self.assignment.submission.pk
         }
         self.assertEqual(Feedback.objects.count(), 0)
-        response = self.client.post(self.url, data, format='json')
+        response = self.client.post(self.url(), data, format='json')
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(Feedback.objects.count(), 0)
 
@@ -159,7 +159,7 @@ class FeedbackCreateTestCase(APITestCase):
             'is_final': True,
             'of_submission': self.assignment.submission.pk
         }
-        response = self.client.post(self.url, data, format='json')
+        response = self.client.post(self.url(), data, format='json')
         self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code)
         self.assertEqual(Feedback.objects.count(), 0)
 
@@ -169,7 +169,7 @@ class FeedbackCreateTestCase(APITestCase):
             'is_final': False,
             'of_submission': self.assignment.submission.pk
         }
-        response = self.client.post(self.url, data, format='json')
+        response = self.client.post(self.url(), data, format='json')
         self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code)
         self.assertEqual(Feedback.objects.count(), 0)
 
@@ -180,7 +180,7 @@ class FeedbackCreateTestCase(APITestCase):
             'of_submission': self.assignment.submission.pk
         }
         self.assertEqual(Feedback.objects.count(), 0)
-        response = self.client.post(self.url, data, format='json')
+        response = self.client.post(self.url(), data, format='json')
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(Feedback.objects.count(), 0)
 
@@ -191,7 +191,7 @@ class FeedbackCreateTestCase(APITestCase):
             'of_submission': self.assignment.submission.pk
         }
         self.assertEqual(Feedback.objects.count(), 0)
-        response = self.client.post(self.url, data, format='json')
+        response = self.client.post(self.url(), data, format='json')
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(Feedback.objects.count(), 0)
 
@@ -209,7 +209,7 @@ class FeedbackCreateTestCase(APITestCase):
             }
         }
         self.assertEqual(self.fst_label.feedback.count(), 0)
-        response = self.client.post(self.url, data, format='json')
+        response = self.client.post(self.url(), data, format='json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.fst_label.refresh_from_db()
         self.snd_label.refresh_from_db()
@@ -229,7 +229,7 @@ class FeedbackCreateTestCase(APITestCase):
                 }
             }
         }
-        self.client.post(self.url, data, format='json')
+        self.client.post(self.url(), data, format='json')
         object_score = self.sub.feedback.score
         self.assertEqual(object_score, 0.5)
 
@@ -245,7 +245,7 @@ class FeedbackCreateTestCase(APITestCase):
                 }
             }
         }
-        self.client.post(self.url, data, format='json')
+        self.client.post(self.url(), data, format='json')
         object_score = self.sub.feedback.score
         self.assertEqual(object_score, 5)
 
@@ -262,7 +262,7 @@ class FeedbackCreateTestCase(APITestCase):
             }
         }
         self.assertEqual(FeedbackComment.objects.count(), 0)
-        response = self.client.post(self.url, data, format='json')
+        response = self.client.post(self.url(), data, format='json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(FeedbackComment.objects.count(), 1)
 
@@ -278,7 +278,7 @@ class FeedbackCreateTestCase(APITestCase):
                 }
             }
         }
-        self.client.post(self.url, data, format='json')
+        self.client.post(self.url(), data, format='json')
         comment = FeedbackComment.objects.first()
         self.assertEqual(comment.of_tutor, self.tutor)
         self.assertEqual(comment.text, 'Nice meth!')
@@ -298,8 +298,8 @@ class FeedbackCreateTestCase(APITestCase):
             }
         }
         self.assignment.delete()
-        response = self.client.post(self.url, data, format='json')
-        self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code)
+        response = self.client.post(self.url(), data, format='json')
+        self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code)
 
     def test_cannot_create_with_someoneelses_assignment(self):
         data = {
@@ -314,8 +314,9 @@ class FeedbackCreateTestCase(APITestCase):
         }
         other_tutor = self.user_factory.make_tutor('Berta')
         self.client.force_authenticate(other_tutor)
-        response = self.client.post(self.url, data, format='json')
-        self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code)
+        response = self.client.post(self.url(), data, format='json')
+        # returns 404 since the other users assignment is not visible to this one
+        self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code)
 
     def test_can_create_multiple_feedback_comments(self):
         data = {
@@ -333,7 +334,7 @@ class FeedbackCreateTestCase(APITestCase):
                 }
             }
         }
-        self.client.post(self.url, data, format='json')
+        self.client.post(self.url(), data, format='json')
         first_comment = FeedbackComment.objects.get(text='Nice meth!')
         self.assertEqual(first_comment.of_tutor, self.tutor)
         self.assertIsNotNone(first_comment.created)
diff --git a/core/tests/test_subscription_assignment_service.py b/core/tests/test_subscription_assignment_service.py
index 79f0f14a..c4522609 100644
--- a/core/tests/test_subscription_assignment_service.py
+++ b/core/tests/test_subscription_assignment_service.py
@@ -235,7 +235,6 @@ class TestApiEndpoints(APITestCase):
 
         response_subscription_create = self.client.post(
             '/api/subscription/', {'query_type': 'random'})
-        subscription_pk = response_subscription_create.data['pk']
 
         subscription_pk = response_subscription_create.data['pk']
         response_assignment = self.client.post(
@@ -374,7 +373,7 @@ class TestApiEndpoints(APITestCase):
             })
         self.assertEqual(status.HTTP_201_CREATED, response.status_code)
         response = self.client.post(
-            f'/api/feedback/', {
+            f'/api/assignment/{response.data["pk"]}/finish/', {
                 "score": 23,
                 "of_submission": response.data['submission']['pk'],
                 "feedback_lines": {
@@ -415,8 +414,8 @@ class TestApiEndpoints(APITestCase):
         assignment = models.TutorSubmissionAssignment.objects.get(
             pk=response.data['pk'])
         self.assertFalse(assignment.is_done)
-        response = self.client.patch(
-            '/api/feedback/%s/' % submission_id_in_response, {
+        response = self.client.post(
+            f'/api/assignment/{assignment.pk}/finish/', {
                 "score": 20,
                 "is_final": True,
                 "feedback_lines": {
diff --git a/core/tests/test_tutor_api_endpoints.py b/core/tests/test_tutor_api_endpoints.py
index 7d0f0fc7..343b649d 100644
--- a/core/tests/test_tutor_api_endpoints.py
+++ b/core/tests/test_tutor_api_endpoints.py
@@ -107,6 +107,7 @@ class TutorListTests(APITestCase):
             Feedback.objects.update_or_create(
                 of_submission=assignment.submission,
                 score=35)
+            assignment.finish()
 
         tutor01 = data['tutors'][0]
         tutor02 = data['tutors'][1]
diff --git a/core/urls.py b/core/urls.py
index b0e100b4..aee6e268 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -7,6 +7,7 @@ from rest_framework.permissions import AllowAny
 from core import views
 
 # Create a router and register our viewsets with it.
+
 router = DefaultRouter()
 router.register('student', views.StudentReviewerApiViewSet,
                 basename='student')
diff --git a/core/views/feedback.py b/core/views/feedback.py
index e7ccfb22..b034edd1 100644
--- a/core/views/feedback.py
+++ b/core/views/feedback.py
@@ -4,8 +4,11 @@ from multiprocessing import Lock
 from rest_framework import mixins, status, viewsets
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.response import Response
+from rest_framework import decorators
 
 from core import models, permissions, serializers
+from core.views.util import tutor_attempts_to_patch_first_feedback_final, \
+    get_implicit_assignment_for_user
 
 log = logging.getLogger(__name__)
 
@@ -17,11 +20,11 @@ class FeedbackApiView(
         viewsets.GenericViewSet):
     """ Gets a list of an individual exam by Id if provided """
     permission_classes = (permissions.IsTutorOrReviewer,)
-    queryset = models.Feedback.objects\
-        .select_related('of_submission')\
-        .select_related('of_submission__type')\
-        .select_related('of_submission__student')\
-        .select_related('of_submission__student__user')\
+    queryset = models.Feedback.objects \
+        .select_related('of_submission') \
+        .select_related('of_submission__type') \
+        .select_related('of_submission__student') \
+        .select_related('of_submission__student__user') \
         .all()
     serializer_class = serializers.FeedbackSerializer
     lookup_field = 'of_submission__pk'
@@ -32,44 +35,11 @@ class FeedbackApiView(
         user_is_tutor = self.request.user.role == models.UserAccount.TUTOR
         return feedback_final_by_reviewer and user_is_tutor
 
-    def _get_implicit_assignment_for_user(self, submission):
-        """ Check for tutor if it exists. Not relevant for reviewer """
-        try:
-            return models.TutorSubmissionAssignment.objects.get(
-                subscription__owner=self.request.user,
-                submission=submission
-            )
-        except models.TutorSubmissionAssignment.DoesNotExist:
-            if self.request.user.role == models.UserAccount.REVIEWER:
-                return None
-            raise PermissionDenied(
-                detail='This user has no permission to create this feedback')
-
     def _tutor_attempts_to_set_first_feedback_final(self, serializer):
         is_final_set = serializer.validated_data.get('is_final', False)
         user_is_tutor = self.request.user.role == models.UserAccount.TUTOR
         return is_final_set and user_is_tutor
 
-    # unused
-    def _tutor_is_allowed_to_change_own_feedback(self, serializer):
-        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):
-        if self.request.user.role == models.UserAccount.REVIEWER:
-            return False
-        is_final_set = serializer.validated_data.get('is_final', False)
-        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
-
     def get_queryset(self):
         if self.request.user.is_reviewer():
             return self.queryset \
@@ -81,19 +51,16 @@ class FeedbackApiView(
             of_submission__assignments__subscription__owner=self.request.user
         )
 
+    @decorators.permission_classes((permissions.IsReviewer,))
     def create(self, request, *args, **kwargs):
         serializer = self.get_serializer(data=request.data)
         serializer.is_valid(raise_exception=True)
-
-        self._get_implicit_assignment_for_user(
-            serializer.validated_data['of_submission'])
-
-        if self._tutor_attempts_to_set_first_feedback_final(serializer):
-            return Response(
-                {'For tutors it is not allowed to create feedback final.'},
-                status=status.HTTP_403_FORBIDDEN)
-
         self.perform_create(serializer)
+
+        # update MetaSubmission information
+        meta = serializer.validated_data.get('of_submission').meta
+        # TODO add reviewer final
+        meta.feedback_authors.add(self.request.user)
         return Response(serializer.data,
                         status=status.HTTP_201_CREATED)
 
@@ -103,16 +70,17 @@ class FeedbackApiView(
                                          partial=True)
         serializer.is_valid(raise_exception=True)
 
-        self._get_implicit_assignment_for_user(feedback.of_submission)
+        assignment = get_implicit_assignment_for_user(feedback.of_submission, self.request.user)
 
+        # TODO change this to check reviewer final
         if self._tutor_attempts_to_change_final_feedback_of_reviewer(serializer):  # noqa
             raise PermissionDenied(
                 detail="Changing final feedback is not allowed.")
 
-        if self._tutor_attempts_to_patch_first_feedback_final(serializer):
+        if tutor_attempts_to_patch_first_feedback_final(serializer, self.request.user, assignment):
             raise PermissionDenied(
                 detail='Cannot set the first feedback final.')
-
+        # TODO add reviewer final
         serializer.save()
         return Response(serializer.data)
 
diff --git a/core/views/subscription.py b/core/views/subscription.py
index bf77f467..2ea0b0c0 100644
--- a/core/views/subscription.py
+++ b/core/views/subscription.py
@@ -2,7 +2,8 @@ import logging
 
 from django.core.exceptions import ObjectDoesNotExist
 from rest_framework import mixins, status, viewsets
-from rest_framework.decorators import action, permission_classes
+from rest_framework import decorators
+from rest_framework.exceptions import PermissionDenied
 from rest_framework.response import Response
 
 from core import models, permissions, serializers
@@ -12,6 +13,8 @@ from core.serializers import AssignmentDetailSerializer, AssignmentSerializer
 
 from multiprocessing import Lock
 
+from core.views.util import tutor_attempts_to_patch_first_feedback_final
+
 log = logging.getLogger(__name__)
 
 
@@ -76,6 +79,7 @@ class AssignmentApiViewSet(
     queryset = TutorSubmissionAssignment.objects\
         .select_related('subscription').all()
     serializer_class = AssignmentSerializer
+    permission_classes = (IsTutorOrReviewer, )
 
     def get_queryset(self):
         if self.action in ['list', 'active', 'destroy']:
@@ -97,11 +101,11 @@ class AssignmentApiViewSet(
                             status=status.HTTP_403_FORBIDDEN)
         return Response(serializer.data, status=status.HTTP_201_CREATED)
 
-    @permission_classes((IsReviewer,))
+    @decorators.permission_classes((IsReviewer,))
     def list(self, *args, **kwargs):
         return super().list(*args, **kwargs)
 
-    @action(detail=False, permission_classes=(IsReviewer,), methods=['get', 'delete'])
+    @decorators.action(detail=False, permission_classes=(IsReviewer,), methods=['get', 'delete'])
     def active(self, request):
         if request.method == 'GET':
             queryset = self.get_queryset().filter(is_done=False)
@@ -111,7 +115,36 @@ class AssignmentApiViewSet(
             self.get_queryset().filter(is_done=False).delete()
             return Response(status=status.HTTP_204_NO_CONTENT)
 
-    @permission_classes((IsTutorOrReviewer,))
+    @decorators.action(detail=True, methods=['post'])
+    def finish(self, request, *args, **kwargs):
+        context = self.get_serializer_context()
+        instance = self.get_object()
+        if instance.is_done or (instance.subscription.owner != request.user):
+            return Response(status=status.HTTP_403_FORBIDDEN)
+        try:
+            orig_feedback = instance.submission.feedback
+            serializer = serializers.FeedbackSerializer(
+                orig_feedback,
+                data=request.data,
+                context=context,
+                partial=True)
+        except models.Feedback.DoesNotExist:
+            serializer = serializers.FeedbackSerializer(
+                data=request.data,
+                context=context)
+
+        serializer.is_valid(raise_exception=True)
+        if tutor_attempts_to_patch_first_feedback_final(serializer, self.request.user, instance):
+            raise PermissionDenied(
+                detail='Cannot set the first feedback final.')
+        # TODO check if is reviewer final
+        serializer.save()
+        instance.finish()
+        response_status = status.HTTP_201_CREATED if \
+            instance.subscription.feedback_stage == \
+            models.SubmissionSubscription.FEEDBACK_CREATION else status.HTTP_200_OK
+        return Response(serializer.data, status=response_status)
+
     def destroy(self, request, pk=None):
         """ Stop working on the assignment before it is finished """
         instance = self.get_object()
@@ -123,7 +156,6 @@ class AssignmentApiViewSet(
         instance.delete()
         return Response(status=status.HTTP_204_NO_CONTENT)
 
-    @permission_classes((IsTutorOrReviewer,))
     def create(self, request, *args, **kwargs):
         with Lock():
             context = self.get_serializer_context()
@@ -133,7 +165,6 @@ class AssignmentApiViewSet(
             assignment = self._fetch_assignment(serializer)
         return assignment
 
-    @permission_classes((IsTutorOrReviewer,))
     def retrieve(self, request, *args, **kwargs):
         assignment = self.get_object()
         if assignment.subscription.owner != request.user:
diff --git a/core/views/util.py b/core/views/util.py
new file mode 100644
index 00000000..b0301873
--- /dev/null
+++ b/core/views/util.py
@@ -0,0 +1,33 @@
+from rest_framework.exceptions import PermissionDenied
+
+from core import models
+
+
+class NoAssignmentForTutor(Exception):
+    pass
+
+
+def tutor_attempts_to_patch_first_feedback_final(feedback_serializer,
+                                                 user,
+                                                 assignment=None):
+    if user.role == models.UserAccount.REVIEWER:
+        return False
+    if user.role == models.UserAccount.TUTOR and assignment is None:
+        raise NoAssignmentForTutor()
+    is_final_set = feedback_serializer.validated_data.get('is_final', False)
+    in_creation = assignment.subscription.feedback_stage == models.SubmissionSubscription.FEEDBACK_CREATION  # noqa
+    return is_final_set and in_creation
+
+
+def get_implicit_assignment_for_user(submission, user):
+    """ Check for tutor if it exists. Not relevant for reviewer """
+    try:
+        return models.TutorSubmissionAssignment.objects.get(
+            subscription__owner=user,
+            submission=submission
+        )
+    except models.TutorSubmissionAssignment.DoesNotExist:
+        if user.role == models.UserAccount.REVIEWER:
+            return None
+        raise PermissionDenied(
+            detail='This user has no permission to create this feedback')
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index d6a1d838..e1c7f83b 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -115,7 +115,7 @@ export async function fetchStatistics (): Promise<Statistics> {
   return (await ax.get(url)).data
 }
 
-export async function fetchLabelStatistics (): Promise<LabelStatisticsForSubType  []> {
+export async function fetchLabelStatistics (): Promise<LabelStatisticsForSubType[]> {
   const url = '/api/label-statistics'
   return (await ax.get(url)).data
 }
@@ -152,16 +152,20 @@ export async function createAssignment (
   return (await ax.post(`/api/assignment/`, data)).data
 }
 
-export async function submitFeedbackForAssignment ({ feedback }: 
-  { feedback: Partial<CreateUpdateFeedback>}): Promise<CreateUpdateFeedback> {
-  return (await ax.post('/api/feedback/', feedback)).data
+export async function submitFeedbackForAssignment ({ feedback, assignment }:
+  { feedback: Partial<CreateUpdateFeedback>, assignment: Assignment}): Promise<CreateUpdateFeedback> {
+  return (await ax.post(`/api/assignment/${assignment.pk}/finish`, feedback)).data
 }
 
-export async function submitUpdatedFeedback ({ feedback }: 
+export async function submitUpdatedFeedback ({ feedback }:
   {feedback: CreateUpdateFeedback}): Promise<CreateUpdateFeedback> {
   return (await ax.patch(`/api/feedback/${feedback.ofSubmission}/`, feedback)).data
 }
 
+export async function submitFeedback ({ feedback }: {feedback: CreateUpdateFeedback}): Promise<Feedback> {
+  return (await ax.post(`/api/feedback/`, feedback)).data
+}
+
 export async function fetchSubmissionTypes (): Promise<Array<SubmissionType>> {
   const url = '/api/submissiontype/'
   return (await ax.get(url)).data
@@ -221,7 +225,7 @@ export async function createLabel (payload: Partial<FeedbackLabel>) {
 }
 
 export async function updateLabel (payload: FeedbackLabel) {
-  return (await ax.put('/api/label/' + payload.pk + '/', payload))
+  return (await ax.put('/api/label/' + payload.pk + '/', payload)).data
 }
 
 export interface StudentExportOptions { setPasswords?: boolean }
@@ -250,6 +254,6 @@ export async function fetchInstanceExportData (): Promise<InstanceExportData> {
   return (await ax.get('/api/instance/export')).data
 }
 
-ax.interceptors.response.use(undefined, errorInterceptor);
+ax.interceptors.response.use(undefined, errorInterceptor)
 
 export default ax
diff --git a/frontend/src/store/modules/submission-notes.ts b/frontend/src/store/modules/submission-notes.ts
index 8247114b..50fa49b0 100644
--- a/frontend/src/store/modules/submission-notes.ts
+++ b/frontend/src/store/modules/submission-notes.ts
@@ -4,8 +4,9 @@ import * as api from '@/api'
 import { Feedback, FeedbackComment, SubmissionNoType, CreateUpdateFeedback } from '@/models'
 import { RootState } from '@/store/store'
 import { getStoreBuilder, BareActionContext } from 'vuex-typex'
-import { syntaxPostProcess } from '@/util/helpers';
-import { AxiosResponse } from 'axios';
+import { syntaxPostProcess } from '@/util/helpers'
+import { AxiosResponse } from 'axios'
+import { Subscriptions } from './subscriptions';
 
 export interface SubmissionNotesState {
   submission: SubmissionNoType
@@ -21,7 +22,7 @@ export interface SubmissionNotesState {
   changedLabels: boolean
 }
 
-function initialState(): SubmissionNotesState {
+function initialState (): SubmissionNotesState {
   return {
     submission: {
       text: '',
@@ -194,11 +195,15 @@ Promise<AxiosResponse<void>[]> {
   } else if (feedback.score! < SubmissionNotes.submissionType.fullScore! && !state.hasOrigFeedback) {
     throw new Error('You need to add or change a comment when setting a non full score.')
   }
-  if (!state.hasOrigFeedback) {
-    await api.submitFeedbackForAssignment({ feedback })
-  } else {
+
+  const assignment = Subscriptions.state.currentAssignment
+  if (assignment) {
+    await api.submitFeedbackForAssignment({ feedback , assignment})
+  } else if (state.origFeedback) {
     feedback.pk = state.origFeedback.pk
     await api.submitUpdatedFeedback(<{ feedback: CreateUpdateFeedback }>{ feedback })
+  } else {
+    await api.submitFeedback(<{ feedback: CreateUpdateFeedback }>{feedback})
   }
   // delete those comments that have been marked for deletion
   return SubmissionNotes.deleteComments()
diff --git a/functional_tests/test_export_modal.py b/functional_tests/test_export_modal.py
index e6af2003..6fa6aa16 100644
--- a/functional_tests/test_export_modal.py
+++ b/functional_tests/test_export_modal.py
@@ -117,8 +117,8 @@ class ExportTestModal(LiveServerTestCase):
         os.remove(JSON_EXPORT_FILE)
 
     def test_export_instance(self):
-        self._login()
         fact.SubmissionFactory()
+        self._login()
         self.browser.find_element_by_id('export-btn').click()
         self.browser.find_element_by_id('export-list1').click()
         instance_export_modal = self.browser.find_element_by_id('instance-export-modal')
-- 
GitLab


From 93eb267c01ce22d33ed8c6f5d3a88a5c820c006f Mon Sep 17 00:00:00 2001
From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de>
Date: Wed, 14 Aug 2019 15:10:29 +0200
Subject: [PATCH 2/8] Added test case

---
 functional_tests/test_feedback_creation.py |  9 +--
 functional_tests/test_feedback_update.py   | 85 ++++++++++++++++++++++
 functional_tests/util.py                   | 12 +++
 3 files changed, 99 insertions(+), 7 deletions(-)
 create mode 100644 functional_tests/test_feedback_update.py

diff --git a/functional_tests/test_feedback_creation.py b/functional_tests/test_feedback_creation.py
index c85c84be..1cf9caa3 100644
--- a/functional_tests/test_feedback_creation.py
+++ b/functional_tests/test_feedback_creation.py
@@ -8,6 +8,7 @@ from selenium.webdriver.common.by import By
 from core.models import UserAccount, Submission, FeedbackComment
 from functional_tests.util import (login, create_browser, reset_browser_after_test,
                                    go_to_subscription, wait_until_code_changes,
+                                   correct_some_submission,
                                    reconstruct_submission_code, wait_until_element_count_equals)
 from util import factory_boys as fact
 
@@ -95,13 +96,7 @@ class UntestedParent:
         def test_can_give_max_score(self):
             self._login()
             go_to_subscription(self)
-            code = reconstruct_submission_code(self)
-            self.browser.find_element_by_id('score-full').click()
-            submit_btn = self.browser.find_element_by_id('submit-feedback')
-            submit_btn.click()
-            WebDriverWait(self.browser, 10).until(
-                wait_until_code_changes(self, code)
-            )
+            code = correct_some_submission()
             submission_for_code = Submission.objects.get(text=code)
             self.assertEqual(self.sub_type.full_score, submission_for_code.feedback.score)
 
diff --git a/functional_tests/test_feedback_update.py b/functional_tests/test_feedback_update.py
new file mode 100644
index 00000000..f2848b88
--- /dev/null
+++ b/functional_tests/test_feedback_update.py
@@ -0,0 +1,85 @@
+from django.test import LiveServerTestCase
+from selenium import webdriver
+from selenium.webdriver import ActionChains
+from selenium.webdriver.common.by import By
+from selenium.webdriver.common.keys import Keys
+from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.support import expected_conditions as ec
+
+from core.models import FeedbackLabel
+from functional_tests.util import (login, create_browser, reset_browser_after_test,
+                                   query_returns_object, go_to_subscription,
+                                   reconstruct_submission_code, wait_until_code_changes,
+                                   correct_some_submission)
+from util import factory_boys as fact
+
+import time
+
+class TestFeedbackUpdate(LiveServerTestCase):
+    browser: webdriver.Firefox = None
+    username = None
+    password = None
+    role = None
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.browser = create_browser()
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+        cls.browser.quit()
+
+    def setUp(self):
+        super().setUp()
+        self.username = 'tut'
+        self.password = 'p'
+        fact.UserAccountFactory(
+            username=self.username,
+            password=self.password,
+        )
+        self.sub_type = fact.SubmissionTypeFactory.create()
+        fact.SubmissionFactory.create_batch(2, type=self.sub_type)
+
+    def _login(self):
+        login(self.browser, self.live_server_url, self.username, self.password)
+
+    def test_updating_own_feedback_doesnt_invalidate_other_tutors_assignment(self):
+        self._login()
+        code = correct_some_submission(self)
+        first_tab = self.browser.current_window_handle
+
+        # body = self.browser.find_element_by_tag_name('body')
+        # body.send_keys(Keys.LEFT_CONTROL + 't')
+        self.browser.execute_script('window.open()')
+        self.browser.switch_to.window(self.browser.window_handles[-1])
+        second_tab = self.browser.current_window_handle
+        self.browser.execute_script("window.sessionStorage.clear()")
+        self.browser.get(self.live_server_url)
+        username = 'other_tut'
+        password = 'p'
+        fact.UserAccountFactory(
+            username=username,
+            password=password
+        )
+        login(self.browser, self.live_server_url, username, password)
+        go_to_subscription(self, stage='validate')
+        other_code = reconstruct_submission_code(self)
+        self.assertEqual(
+            code, other_code,
+            "Code for validation submissions is different than initial")
+
+        self.browser.switch_to.window(first_tab)
+        self.browser.find_element_by_partial_link_text('Feedback History').click()
+        # time.sleep(3)
+        feedback_entry = self.browser.find_element_by_class_name('feedback-row')
+        ActionChains(self.browser).move_to_element(feedback_entry).click().perform()
+        self.browser.find_element_by_id('submit-feedback').click()
+
+        self.browser.switch_to.window(second_tab)
+        self.browser.find_element_by_id('submit-feedback').click()
+        WebDriverWait(self.browser, 10).until(
+            ec.url_contains('ended'),
+            'Browser is not on Subscription ended site, therefore Feedback could not be submitted'
+        )
diff --git a/functional_tests/util.py b/functional_tests/util.py
index 728542e2..7d10f0dc 100644
--- a/functional_tests/util.py
+++ b/functional_tests/util.py
@@ -106,6 +106,18 @@ def go_to_subscription(test_class_instance, stage='initial', sub_type=None):
     WebDriverWait(test_class_instance.browser, 10).until(ec.url_contains('subscription'))
 
 
+def correct_some_submission(test_class_instance):
+    go_to_subscription(test_class_instance)
+    code = reconstruct_submission_code(test_class_instance)
+    test_class_instance.browser.find_element_by_id('score-full').click()
+    submit_btn = test_class_instance.browser.find_element_by_id('submit-feedback')
+    submit_btn.click()
+    WebDriverWait(test_class_instance.browser, 10).until(
+        wait_until_code_changes(test_class_instance, code)
+    )
+    return code
+
+
 def reconstruct_submission_code(test_class_instance):
     sub_table = test_class_instance.browser.find_element_by_class_name('submission-table')
     lines = sub_table.find_elements_by_tag_name('tr')
-- 
GitLab


From dacfbeb39093a5cce904f3c9bb374c4fc97ba58a Mon Sep 17 00:00:00 2001
From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de>
Date: Wed, 14 Aug 2019 18:00:50 +0200
Subject: [PATCH 3/8] Fix after rebase

---
 core/models/assignment.py                  |  1 -
 core/serializers/feedback.py               |  8 ++++----
 core/signals.py                            |  2 +-
 core/views/feedback.py                     |  5 +----
 core/views/subscription.py                 |  5 ++++-
 functional_tests/test_feedback_creation.py |  2 +-
 functional_tests/test_feedback_update.py   | 20 ++++++++------------
 7 files changed, 19 insertions(+), 24 deletions(-)

diff --git a/core/models/assignment.py b/core/models/assignment.py
index 77967461..2f9151bc 100644
--- a/core/models/assignment.py
+++ b/core/models/assignment.py
@@ -42,7 +42,6 @@ class TutorSubmissionAssignment(models.Model):
             raise CanOnlyCallFinishOnUnfinishedAssignments()
 
         meta = self.submission.meta
-        # TODO add reviewer final
         meta.feedback_authors.add(self.subscription.owner)
         meta.done_assignments += 1
         meta.has_active_assignment = False
diff --git a/core/serializers/feedback.py b/core/serializers/feedback.py
index 19a430cc..8e9f6e69 100644
--- a/core/serializers/feedback.py
+++ b/core/serializers/feedback.py
@@ -124,11 +124,11 @@ class FeedbackSerializer(DynamicFieldsModelSerializer):
         feedback_lines = validated_data.pop('feedback_lines', [])
         labels = validated_data.pop('labels', [])
         user = self.context['request'].user
+        final_by_reviewer = validated_data.pop('is_final', False) and \
+            user.role == UserAccount.REVIEWER
         feedback = Feedback.objects.create(of_submission=submission,
+                                           final_by_reviewer=final_by_reviewer,
                                            **validated_data)
-        if user.role == UserAccount.REVIEWER:
-            feedback.final_by_reviewer = self.context['request'].data['is_final']
-
         for label in labels:
             feedback.labels.add(label)
 
@@ -143,7 +143,7 @@ class FeedbackSerializer(DynamicFieldsModelSerializer):
             )
             comment_instance.labels.set(labels)
 
-        return Feedback.objects.get(of_submission=submission)
+        return feedback
 
     @transaction.atomic
     def update(self, feedback, validated_data):
diff --git a/core/signals.py b/core/signals.py
index 110a15e8..754dd966 100644
--- a/core/signals.py
+++ b/core/signals.py
@@ -49,7 +49,7 @@ def update_after_feedback_save(sender, instance, created, **kwargs):
     log.debug('SIGNAL -- update_after_feedback_save')
     meta = instance.of_submission.meta
     meta.has_feedback = True
-    meta.has_final_feedback = instance.is_final
+    meta.has_final_feedback = instance.is_final or instance.final_by_reviewer
     meta.save()
 
 
diff --git a/core/views/feedback.py b/core/views/feedback.py
index b034edd1..a1107208 100644
--- a/core/views/feedback.py
+++ b/core/views/feedback.py
@@ -59,7 +59,6 @@ class FeedbackApiView(
 
         # update MetaSubmission information
         meta = serializer.validated_data.get('of_submission').meta
-        # TODO add reviewer final
         meta.feedback_authors.add(self.request.user)
         return Response(serializer.data,
                         status=status.HTTP_201_CREATED)
@@ -72,15 +71,13 @@ class FeedbackApiView(
 
         assignment = get_implicit_assignment_for_user(feedback.of_submission, self.request.user)
 
-        # TODO change this to check reviewer final
         if self._tutor_attempts_to_change_final_feedback_of_reviewer(serializer):  # noqa
             raise PermissionDenied(
-                detail="Changing final feedback is not allowed.")
+                detail="Changing feedback set to final by a reviewer is not allowed.")
 
         if tutor_attempts_to_patch_first_feedback_final(serializer, self.request.user, assignment):
             raise PermissionDenied(
                 detail='Cannot set the first feedback final.')
-        # TODO add reviewer final
         serializer.save()
         return Response(serializer.data)
 
diff --git a/core/views/subscription.py b/core/views/subscription.py
index 2ea0b0c0..ec052529 100644
--- a/core/views/subscription.py
+++ b/core/views/subscription.py
@@ -128,6 +128,10 @@ class AssignmentApiViewSet(
                 data=request.data,
                 context=context,
                 partial=True)
+            if orig_feedback.final_by_reviewer and request.user.role == models.UserAccount.TUTOR:
+                raise PermissionDenied(detail="Unfortunately you won't be able to finish this"
+                                              "assignment since a reviewer has marked it as "
+                                              "final while you were assigned.")
         except models.Feedback.DoesNotExist:
             serializer = serializers.FeedbackSerializer(
                 data=request.data,
@@ -137,7 +141,6 @@ class AssignmentApiViewSet(
         if tutor_attempts_to_patch_first_feedback_final(serializer, self.request.user, instance):
             raise PermissionDenied(
                 detail='Cannot set the first feedback final.')
-        # TODO check if is reviewer final
         serializer.save()
         instance.finish()
         response_status = status.HTTP_201_CREATED if \
diff --git a/functional_tests/test_feedback_creation.py b/functional_tests/test_feedback_creation.py
index 1cf9caa3..bc81158a 100644
--- a/functional_tests/test_feedback_creation.py
+++ b/functional_tests/test_feedback_creation.py
@@ -96,7 +96,7 @@ class UntestedParent:
         def test_can_give_max_score(self):
             self._login()
             go_to_subscription(self)
-            code = correct_some_submission()
+            code = correct_some_submission(self)
             submission_for_code = Submission.objects.get(text=code)
             self.assertEqual(self.sub_type.full_score, submission_for_code.feedback.score)
 
diff --git a/functional_tests/test_feedback_update.py b/functional_tests/test_feedback_update.py
index f2848b88..30bf1a2d 100644
--- a/functional_tests/test_feedback_update.py
+++ b/functional_tests/test_feedback_update.py
@@ -1,19 +1,13 @@
 from django.test import LiveServerTestCase
 from selenium import webdriver
 from selenium.webdriver import ActionChains
-from selenium.webdriver.common.by import By
-from selenium.webdriver.common.keys import Keys
-from selenium.webdriver.support.ui import WebDriverWait
 from selenium.webdriver.support import expected_conditions as ec
+from selenium.webdriver.support.ui import WebDriverWait
 
-from core.models import FeedbackLabel
-from functional_tests.util import (login, create_browser, reset_browser_after_test,
-                                   query_returns_object, go_to_subscription,
-                                   reconstruct_submission_code, wait_until_code_changes,
-                                   correct_some_submission)
+from functional_tests.util import (login, create_browser, go_to_subscription,
+                                   reconstruct_submission_code, correct_some_submission)
 from util import factory_boys as fact
 
-import time
 
 class TestFeedbackUpdate(LiveServerTestCase):
     browser: webdriver.Firefox = None
@@ -46,12 +40,12 @@ class TestFeedbackUpdate(LiveServerTestCase):
         login(self.browser, self.live_server_url, self.username, self.password)
 
     def test_updating_own_feedback_doesnt_invalidate_other_tutors_assignment(self):
+        # First correct some submission as the first tutor
         self._login()
         code = correct_some_submission(self)
         first_tab = self.browser.current_window_handle
 
-        # body = self.browser.find_element_by_tag_name('body')
-        # body.send_keys(Keys.LEFT_CONTROL + 't')
+        # open a new tab and go to the validation page of the just corrected submission
         self.browser.execute_script('window.open()')
         self.browser.switch_to.window(self.browser.window_handles[-1])
         second_tab = self.browser.current_window_handle
@@ -66,17 +60,19 @@ class TestFeedbackUpdate(LiveServerTestCase):
         login(self.browser, self.live_server_url, username, password)
         go_to_subscription(self, stage='validate')
         other_code = reconstruct_submission_code(self)
+        # The submission to be validated should be the same as the one that has been corrected
         self.assertEqual(
             code, other_code,
             "Code for validation submissions is different than initial")
 
+        # Go to first tab and update the submission as the first tutor via the Feedback History page
         self.browser.switch_to.window(first_tab)
         self.browser.find_element_by_partial_link_text('Feedback History').click()
-        # time.sleep(3)
         feedback_entry = self.browser.find_element_by_class_name('feedback-row')
         ActionChains(self.browser).move_to_element(feedback_entry).click().perform()
         self.browser.find_element_by_id('submit-feedback').click()
 
+        # as the second tutor, submit the validated feedback
         self.browser.switch_to.window(second_tab)
         self.browser.find_element_by_id('submit-feedback').click()
         WebDriverWait(self.browser, 10).until(
-- 
GitLab


From e1d1d9ff496ac00d0b0ede18189d69d6edd8175a Mon Sep 17 00:00:00 2001
From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de>
Date: Wed, 14 Aug 2019 18:45:09 +0200
Subject: [PATCH 4/8] Added screenshot on fail for export test

---
 .gitignore                            | 1 +
 .gitlab-ci.yml                        | 8 ++++++--
 functional_tests/test_export_modal.py | 9 ++++++++-
 3 files changed, 15 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore
index 391b49d5..efc87898 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,7 @@ coverage_html/
 anon-export/
 public/
 geckodriver.log
+.screenshots
 
 # node
 node_modules
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 9c6ce22d..be215ebd 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -25,7 +25,7 @@ build_test_env:
   artifacts:
     paths:
       - .venv/
-    expire_in: 20 minutes
+    expire_in: 1 days
   cache:
     key: "$CI_JOB_NAME"
     paths:
@@ -44,7 +44,7 @@ build_frontend:
     paths:
       - frontend/dist
       - frontend/node_modules/
-    expire_in: 20 minutes
+    expire_in: 1 days
   cache:
     key: "$CI_JOB_NAME"
     paths:
@@ -106,6 +106,10 @@ test_frontend:
     - python util/format_index.py
     - python manage.py collectstatic --no-input
     - HEADLESS_TESTS=True pytest --ds=grady.settings.test functional_tests
+  artifacts:
+    paths:
+      - .screenshots
+    expire_in: 30 days
 
 test_frontend_unit:
   image: node:carbon
diff --git a/functional_tests/test_export_modal.py b/functional_tests/test_export_modal.py
index 6fa6aa16..afa74d6e 100644
--- a/functional_tests/test_export_modal.py
+++ b/functional_tests/test_export_modal.py
@@ -51,6 +51,13 @@ class ExportTestModal(LiveServerTestCase):
         )
 
     def tearDown(self):
+        try:
+            os.mkdir('.screenshots')
+        except FileExistsError:
+            pass
+        for method, error in self._outcome.errors:
+            if error:
+                self.browser.get_screenshot_as_file(".screenshots/" + self.id() + ".png")
         reset_browser_after_test(self.browser, self.live_server_url)
 
     def _login(self):
@@ -97,8 +104,8 @@ class ExportTestModal(LiveServerTestCase):
                          list_elements[1].find_element_by_tag_name('a').text)
 
     def test_export_student_scores_as_json(self):
-        self._login()
         fact.StudentInfoFactory()
+        self._login()
         export_btn = self.browser.find_element_by_id('export-btn')
         export_btn.click()
         export_scores = self.browser.find_element_by_id('export-list0')
-- 
GitLab


From 4cd83e41560629a5b12f8f975b0419c049710bc9 Mon Sep 17 00:00:00 2001
From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de>
Date: Wed, 14 Aug 2019 19:13:42 +0200
Subject: [PATCH 5/8] Parralelized tests

---
 .gitlab-ci.yml       | 4 ++--
 Makefile             | 6 +++---
 requirements.dev.txt | 2 ++
 3 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index be215ebd..37677087 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -70,7 +70,7 @@ test_pytest:
   services:
     - postgres:9.6
   script:
-    - pytest --cov --ds=grady.settings.test core/tests
+    - pytest -n 4 --cov --ds=grady.settings.test core/tests
   artifacts:
     paths:
       - .coverage
@@ -105,7 +105,7 @@ test_frontend:
     - cp frontend/dist/index.html core/templates
     - python util/format_index.py
     - python manage.py collectstatic --no-input
-    - HEADLESS_TESTS=True pytest --ds=grady.settings.test functional_tests
+    - HEADLESS_TESTS=True pytest -n 4 --ds=grady.settings.test functional_tests
   artifacts:
     paths:
       - .screenshots
diff --git a/Makefile b/Makefile
index b20a9c5d..24452859 100644
--- a/Makefile
+++ b/Makefile
@@ -28,13 +28,13 @@ install:
 	pip install -Ur requirements.dev.txt
 
 test:
-	DJANGO_SETTINGS_MODULE=grady.settings pytest
+	pytest -n 4 --ds=grady.settings core/tests
 
 teste2e:
-	cd frontend && yarn build && cp dist/index.html ../core/templates && cd .. && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest --ds=grady.settings $(path); git checkout core/templates/index.html
+	cd frontend && yarn build && cp dist/index.html ../core/templates && cd .. && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest -n 4 --ds=grady.settings $(path); git checkout core/templates/index.html
 
 teste2e-nc:
-	cp frontend/dist/index.html ./core/templates && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest --ds=grady.settings $(path); git checkout core/templates/index.html
+	cp frontend/dist/index.html ./core/templates && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest -n 4 --ds=grady.settings $(path); git checkout core/templates/index.html
 
 
 coverage:
diff --git a/requirements.dev.txt b/requirements.dev.txt
index d883c351..a48c0a16 100644
--- a/requirements.dev.txt
+++ b/requirements.dev.txt
@@ -1,6 +1,8 @@
 flake8~=3.6.0
 pre-commit~=1.13.0
+pytest~=4.4
 pytest-cov~=2.6.0
+pytest-xdist~=1.29
 pytest-django~=3.5.0
 selenium~=3.141.0
 factory-boy~=2.11.0
-- 
GitLab


From 9dd3962d8448287f97e5a9d87c08024af4cff6fd Mon Sep 17 00:00:00 2001
From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de>
Date: Thu, 15 Aug 2019 14:56:54 +0200
Subject: [PATCH 6/8] Removed paralleization

---
 .gitlab-ci.yml                        |  4 +--
 Makefile                              |  4 +--
 functional_tests/test_export_modal.py | 46 +++++++++++++++++----------
 3 files changed, 33 insertions(+), 21 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 37677087..be215ebd 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -70,7 +70,7 @@ test_pytest:
   services:
     - postgres:9.6
   script:
-    - pytest -n 4 --cov --ds=grady.settings.test core/tests
+    - pytest --cov --ds=grady.settings.test core/tests
   artifacts:
     paths:
       - .coverage
@@ -105,7 +105,7 @@ test_frontend:
     - cp frontend/dist/index.html core/templates
     - python util/format_index.py
     - python manage.py collectstatic --no-input
-    - HEADLESS_TESTS=True pytest -n 4 --ds=grady.settings.test functional_tests
+    - HEADLESS_TESTS=True pytest --ds=grady.settings.test functional_tests
   artifacts:
     paths:
       - .screenshots
diff --git a/Makefile b/Makefile
index 24452859..4610651a 100644
--- a/Makefile
+++ b/Makefile
@@ -28,10 +28,10 @@ install:
 	pip install -Ur requirements.dev.txt
 
 test:
-	pytest -n 4 --ds=grady.settings core/tests
+	pytest --ds=grady.settings core/tests
 
 teste2e:
-	cd frontend && yarn build && cp dist/index.html ../core/templates && cd .. && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest -n 4 --ds=grady.settings $(path); git checkout core/templates/index.html
+	cd frontend && yarn build && cp dist/index.html ../core/templates && cd .. && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest  --ds=grady.settings $(path); git checkout core/templates/index.html
 
 teste2e-nc:
 	cp frontend/dist/index.html ./core/templates && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest -n 4 --ds=grady.settings $(path); git checkout core/templates/index.html
diff --git a/functional_tests/test_export_modal.py b/functional_tests/test_export_modal.py
index afa74d6e..4754e72a 100644
--- a/functional_tests/test_export_modal.py
+++ b/functional_tests/test_export_modal.py
@@ -1,5 +1,6 @@
 import json
 import os
+from pathlib import Path
 
 from django.test import LiveServerTestCase
 from selenium import webdriver
@@ -10,14 +11,17 @@ from functional_tests.util import login, create_browser, reset_browser_after_tes
 from util import factory_boys as fact
 
 
-def expect_file_to_be_present(path):
+def expect_file_to_be_downloaded(path):
+    """
+    Checks if a file has finished downloading by checking if a file exists at the path and
+    no accompanying `<path>.part` file.
+    :param path: path to check
+    :return:
+    """
     def condition(*args):
-        try:
-            with open(path):
-                pass
-        except FileNotFoundError:
-            return False
-        return True
+        file_present = Path(path).is_file()
+        partial_file_present = Path(os.path.join(path, '.part')).is_file()
+        return file_present and not partial_file_present
     return condition
 
 
@@ -117,11 +121,15 @@ class ExportTestModal(LiveServerTestCase):
         export_type_json.click()
         data_export_btn = data_export_modal.find_element_by_id('export-data-download-btn')
         data_export_btn.click()
-        WebDriverWait(self.browser, 10).until(expect_file_to_be_present(JSON_EXPORT_FILE))
-        with open(JSON_EXPORT_FILE) as f:
-            data = json.load(f)
-        self.assertEqual('B.Inf.4242 Test Module', data[0]['Exam'])
-        os.remove(JSON_EXPORT_FILE)
+        WebDriverWait(self.browser, 10).until(expect_file_to_be_downloaded(JSON_EXPORT_FILE))
+        try:
+            with open(JSON_EXPORT_FILE) as f:
+                data = json.load(f)
+            self.assertEqual('B.Inf.4242 Test Module', data[0]['Exam'])
+        except Exception as e:
+            raise e
+        finally:
+            os.remove(JSON_EXPORT_FILE)
 
     def test_export_instance(self):
         fact.SubmissionFactory()
@@ -130,8 +138,12 @@ class ExportTestModal(LiveServerTestCase):
         self.browser.find_element_by_id('export-list1').click()
         instance_export_modal = self.browser.find_element_by_id('instance-export-modal')
         instance_export_modal.find_element_by_id('instance-export-dl').click()
-        WebDriverWait(self.browser, 10).until(expect_file_to_be_present(JSON_EXPORT_FILE))
-        with open(JSON_EXPORT_FILE) as f:
-            data = json.load(f)
-        self.assertEqual('B.Inf.4242 Test Module', data['examTypes'][0]['moduleReference'])
-        os.remove(JSON_EXPORT_FILE)
+        WebDriverWait(self.browser, 10).until(expect_file_to_be_downloaded(JSON_EXPORT_FILE))
+        try:
+            with open(JSON_EXPORT_FILE) as f:
+                data = json.load(f)
+            self.assertEqual('B.Inf.4242 Test Module', data['examTypes'][0]['moduleReference'])
+        except Exception as e:
+            raise e
+        finally:
+            os.remove(JSON_EXPORT_FILE)
-- 
GitLab


From 08ba8ad944b9c888cec53e4b51022d9414588b6f Mon Sep 17 00:00:00 2001
From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de>
Date: Thu, 15 Aug 2019 17:14:49 +0200
Subject: [PATCH 7/8] Some fixes

---
 .gitlab-ci.yml                                             | 2 +-
 core/serializers/feedback.py                               | 2 +-
 frontend/src/api.ts                                        | 2 +-
 .../components/submission_notes/SubmissionCorrection.vue   | 7 +++----
 frontend/src/store/modules/submission-notes.ts             | 2 +-
 5 files changed, 7 insertions(+), 8 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index be215ebd..f4982209 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -108,7 +108,7 @@ test_frontend:
     - HEADLESS_TESTS=True pytest --ds=grady.settings.test functional_tests
   artifacts:
     paths:
-      - .screenshots
+      - .screenshots/
     expire_in: 30 days
 
 test_frontend_unit:
diff --git a/core/serializers/feedback.py b/core/serializers/feedback.py
index 8e9f6e69..cccfa16d 100644
--- a/core/serializers/feedback.py
+++ b/core/serializers/feedback.py
@@ -124,7 +124,7 @@ class FeedbackSerializer(DynamicFieldsModelSerializer):
         feedback_lines = validated_data.pop('feedback_lines', [])
         labels = validated_data.pop('labels', [])
         user = self.context['request'].user
-        final_by_reviewer = validated_data.pop('is_final', False) and \
+        final_by_reviewer = validated_data.get('is_final', False) and \
             user.role == UserAccount.REVIEWER
         feedback = Feedback.objects.create(of_submission=submission,
                                            final_by_reviewer=final_by_reviewer,
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index e1c7f83b..7d575c89 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -154,7 +154,7 @@ export async function createAssignment (
 
 export async function submitFeedbackForAssignment ({ feedback, assignment }:
   { feedback: Partial<CreateUpdateFeedback>, assignment: Assignment}): Promise<CreateUpdateFeedback> {
-  return (await ax.post(`/api/assignment/${assignment.pk}/finish`, feedback)).data
+  return (await ax.post(`/api/assignment/${assignment.pk}/finish/`, feedback)).data
 }
 
 export async function submitUpdatedFeedback ({ feedback }:
diff --git a/frontend/src/components/submission_notes/SubmissionCorrection.vue b/frontend/src/components/submission_notes/SubmissionCorrection.vue
index 69be8828..150e8929 100644
--- a/frontend/src/components/submission_notes/SubmissionCorrection.vue
+++ b/frontend/src/components/submission_notes/SubmissionCorrection.vue
@@ -141,10 +141,11 @@ export default {
       this.loading = true
       SubmissionNotes.submitFeedback({
         isFinal: isFinal
-      }).then(feedback => {
+      }).then(_ => {
         SubmissionNotes.RESET_UPDATED_FEEDBACK()
-        SubmissionNotes.SET_ORIG_FEEDBACK(feedback)
         this.$emit('feedbackCreated')
+        this.$emit('feedbackChanged')
+        SubmissionNotes.RESET_MARKED_COMMENTS_FOR_DELETE()
       }).catch(err => {
         // ignore trivial errors as those are handled
         // by an interceptor
@@ -157,8 +158,6 @@ export default {
           duration: -1
         })
       }).finally(() => {
-        this.$emit('feedbackChanged')
-        SubmissionNotes.RESET_MARKED_COMMENTS_FOR_DELETE()
         this.loading = false
       })
     },
diff --git a/frontend/src/store/modules/submission-notes.ts b/frontend/src/store/modules/submission-notes.ts
index 50fa49b0..e65f6794 100644
--- a/frontend/src/store/modules/submission-notes.ts
+++ b/frontend/src/store/modules/submission-notes.ts
@@ -199,7 +199,7 @@ Promise<AxiosResponse<void>[]> {
   const assignment = Subscriptions.state.currentAssignment
   if (assignment) {
     await api.submitFeedbackForAssignment({ feedback , assignment})
-  } else if (state.origFeedback) {
+  } else if (state.hasOrigFeedback) {
     feedback.pk = state.origFeedback.pk
     await api.submitUpdatedFeedback(<{ feedback: CreateUpdateFeedback }>{ feedback })
   } else {
-- 
GitLab


From 4ef9067a0b0b26f987aea2148839d16390b01047 Mon Sep 17 00:00:00 2001
From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de>
Date: Thu, 15 Aug 2019 17:47:28 +0200
Subject: [PATCH 8/8] Fixed screenshots

---
 .gitlab-ci.yml                        | 3 ++-
 functional_tests/.gitignore           | 2 +-
 functional_tests/test_export_modal.py | 7 +++++--
 3 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f4982209..ff2b57e4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -108,7 +108,8 @@ test_frontend:
     - HEADLESS_TESTS=True pytest --ds=grady.settings.test functional_tests
   artifacts:
     paths:
-      - .screenshots/
+      - functional_tests/screenshots/
+    when: on_failure
     expire_in: 30 days
 
 test_frontend_unit:
diff --git a/functional_tests/.gitignore b/functional_tests/.gitignore
index 8380e4bf..975c04db 100644
--- a/functional_tests/.gitignore
+++ b/functional_tests/.gitignore
@@ -1,3 +1,3 @@
 export.json
 export.csv
-
+screenshots
diff --git a/functional_tests/test_export_modal.py b/functional_tests/test_export_modal.py
index 4754e72a..92d08bcc 100644
--- a/functional_tests/test_export_modal.py
+++ b/functional_tests/test_export_modal.py
@@ -26,6 +26,7 @@ def expect_file_to_be_downloaded(path):
 
 
 JSON_EXPORT_FILE = os.path.join(os.path.dirname(__file__), 'export.json')
+SCREENSHOTS = os.path.join(os.path.dirname(__file__), 'screenshots')
 
 
 class ExportTestModal(LiveServerTestCase):
@@ -56,12 +57,14 @@ class ExportTestModal(LiveServerTestCase):
 
     def tearDown(self):
         try:
-            os.mkdir('.screenshots')
+            os.mkdir(SCREENSHOTS)
         except FileExistsError:
             pass
         for method, error in self._outcome.errors:
             if error:
-                self.browser.get_screenshot_as_file(".screenshots/" + self.id() + ".png")
+                self.browser.get_screenshot_as_file(
+                    os.path.join(SCREENSHOTS, self.id() + ".png")
+                )
         reset_browser_after_test(self.browser, self.live_server_url)
 
     def _login(self):
-- 
GitLab