From caee09f7ebefb2953ada07c05995dc3a9faf497e Mon Sep 17 00:00:00 2001
From: janmax <j.michal@stud.uni-goettingen.de>
Date: Sat, 24 Mar 2018 18:27:04 +0100
Subject: [PATCH] Used Django Silk to optimize most database queries

* All views (except the feedback view) do not have a N+1 issue
* Some serializer method fields are now precomputed in annotations
* TODO: Automate the performance tests to ensure performance stays high
* Ran isort
---
 .gitignore                                    |  1 +
 core/migrations/0009_auto_20180320_2335.py    |  3 +-
 core/models.py                                | 57 +++++++++-----
 core/serializers/feedback.py                  | 26 +++++--
 core/serializers/submission.py                |  4 +-
 core/serializers/tutor.py                     | 30 +++-----
 core/tests/test_custom_subscription_filter.py |  5 +-
 core/tests/test_tutor_api_endpoints.py        | 76 +++++++++++++++----
 core/views/common_views.py                    | 26 +++++--
 core/views/export.py                          |  8 +-
 core/views/feedback.py                        | 16 ++--
 11 files changed, 168 insertions(+), 84 deletions(-)

diff --git a/.gitignore b/.gitignore
index 03e37b83..6ea0d4bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,4 @@ anon-export/
 # node
 node_modules
 secret
+dump.sql
diff --git a/core/migrations/0009_auto_20180320_2335.py b/core/migrations/0009_auto_20180320_2335.py
index 692cd7e2..20c74196 100644
--- a/core/migrations/0009_auto_20180320_2335.py
+++ b/core/migrations/0009_auto_20180320_2335.py
@@ -1,8 +1,9 @@
 # Generated by Django 2.0.2 on 2018-03-20 23:35
 
-import core.models
 from django.db import migrations, models
 
+import core.models
+
 
 class Migration(migrations.Migration):
 
diff --git a/core/models.py b/core/models.py
index 406bffc9..49ebf182 100644
--- a/core/models.py
+++ b/core/models.py
@@ -13,10 +13,10 @@ from collections import OrderedDict
 from random import randrange
 from typing import Dict
 
+from django.conf import settings
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import AbstractUser
+from django.contrib.auth.models import AbstractUser, UserManager
 from django.db import models, transaction
-from django.conf import settings
 from django.db.models import (BooleanField, Case, Count, F, IntegerField, Q,
                               QuerySet, Sum, Value, When)
 from django.db.models.functions import Coalesce
@@ -164,6 +164,27 @@ class SubmissionType(models.Model):
             ).order_by('name')
 
 
+class TutorManager(UserManager):
+
+    def get_queryset(self):
+        return super().get_queryset().filter(role=UserAccount.TUTOR)
+
+    def with_feedback_count(self):
+        def _get_counter(stage):
+            return Count(Case(
+                When(
+                    Q(subscriptions__feedback_stage=stage) &
+                    Q(subscriptions__assignments__is_done=True),
+                    then=Value(1))),
+                output_field=IntegerField())
+
+        return self.get_queryset() \
+            .annotate(feedback_created=_get_counter(
+                SubmissionSubscription.FEEDBACK_CREATION)) \
+            .annotate(feedback_validated=_get_counter(
+                SubmissionSubscription.FEEDBACK_VALIDATION))
+
+
 class UserAccount(AbstractUser):
     """
     An abstract base class implementing a fully featured User model with
@@ -172,13 +193,6 @@ class UserAccount(AbstractUser):
     Username and password are required. Other fields are optional.
     """
 
-    user_id = models.UUIDField(primary_key=True,
-                               default=uuid.uuid4,
-                               editable=False)
-
-    fullname = models.CharField('full name', max_length=70, blank=True)
-    is_admin = models.BooleanField(default=False)
-
     STUDENT = 'Student'
     TUTOR = 'Tutor'
     REVIEWER = 'Reviewer'
@@ -189,8 +203,20 @@ class UserAccount(AbstractUser):
         (REVIEWER, 'reviewer')
     )
 
+    # Fields
     role = models.CharField(max_length=50, choices=ROLE_CHOICES)
+    user_id = models.UUIDField(primary_key=True,
+                               default=uuid.uuid4,
+                               editable=False)
+
+    fullname = models.CharField('full name', max_length=70, blank=True)
+    is_admin = models.BooleanField(default=False)
+
+    # Managers
+    objects = UserManager()
+    tutors = TutorManager()
 
+    # Helper methods
     def is_student(self):
         return self.role == 'Student'
 
@@ -200,6 +226,8 @@ class UserAccount(AbstractUser):
     def is_reviewer(self):
         return self.role == 'Reviewer'
 
+    # All of these methods are deprecated and should be replaced by custom
+    # Managers (see tutor manager)
     @classmethod
     def get_students(cls):
         return cls.objects.filter(role=cls.STUDENT)
@@ -260,11 +288,7 @@ class StudentInfo(models.Model):
         self.save()
 
     def score_per_submission(self) -> Dict[str, int]:
-        """ TODO: get rid of it and use an annotation.
-
-        Returns:
-            TYPE: Description
-        """
+        """ TODO: get rid of it and use an annotation. """
         if self.submissions.all():
             return OrderedDict({
                 s.type: s.feedback.score if hasattr(s, 'feedback') else 0
@@ -393,10 +417,7 @@ class Submission(models.Model):
         ordering = ('type__name',)
 
     def __str__(self) -> str:
-        return "Submission of type '{}' from Student '{}'".format(
-            self.type,
-            self.student
-        )
+        return "Submission {}".format(self.pk)
 
 
 class Feedback(models.Model):
diff --git a/core/serializers/feedback.py b/core/serializers/feedback.py
index 9a4317ff..43db2622 100644
--- a/core/serializers/feedback.py
+++ b/core/serializers/feedback.py
@@ -88,14 +88,26 @@ class FeedbackSerializer(DynamicFieldsModelSerializer):
     feedback_stage_for_user = serializers.SerializerMethodField()
 
     def get_feedback_stage_for_user(self, obj):
-        if 'request' in self.context:
-            assignments = obj.of_submission.assignments.filter(  # noqa
-                subscription__owner=self.context['request'].user
-            )
-            if len(assignments) == 0:
-                return None
-        else:
+        """ Search for the assignment of this feedback and report in which
+        stage the tutor has worked on it.
+
+        Note: This method is unorthodox since it mingles the rather dump
+        feedback object with assignment logic. The reverse lookups in the
+        method are not pre-fetched. Remove if possible. """
+        if 'request' not in self.context:
+            return
+
+        # This is only required for tutors
+        user = self.context['request'].user
+        if user.role == models.UserAccount.REVIEWER:
+            return None
+
+        assignments = obj.of_submission.assignments.filter(
+            subscription__owner=user)
+
+        if assignments.count() == 0:
             return None
+
         return assignments[0].subscription.feedback_stage
 
     @transaction.atomic
diff --git a/core/serializers/submission.py b/core/serializers/submission.py
index 8e0b1d08..8366d173 100644
--- a/core/serializers/submission.py
+++ b/core/serializers/submission.py
@@ -2,9 +2,9 @@ from rest_framework import serializers
 
 from core.models import Submission
 from core.serializers import (DynamicFieldsModelSerializer, FeedbackSerializer,
-                              VisibleCommentFeedbackSerializer,
                               SubmissionTypeListSerializer,
-                              SubmissionTypeSerializer, TestSerializer)
+                              SubmissionTypeSerializer, TestSerializer,
+                              VisibleCommentFeedbackSerializer)
 
 
 class SubmissionNoTextFieldsSerializer(DynamicFieldsModelSerializer):
diff --git a/core/serializers/tutor.py b/core/serializers/tutor.py
index 7ebf5f17..c661b097 100644
--- a/core/serializers/tutor.py
+++ b/core/serializers/tutor.py
@@ -1,12 +1,13 @@
 import logging
 
+import django.contrib.auth.password_validation as validators
+from django.core import exceptions
 from rest_framework import serializers
-from .generic import DynamicFieldsModelSerializer
-from util.factories import GradyUserFactory
+
 from core import models
-from django.core import exceptions
+from util.factories import GradyUserFactory
 
-import django.contrib.auth.password_validation as validators
+from .generic import DynamicFieldsModelSerializer
 
 log = logging.getLogger(__name__)
 user_factory = GradyUserFactory()
@@ -21,22 +22,13 @@ class TutorSerializer(DynamicFieldsModelSerializer):
         required=False
     )
 
-    @staticmethod
-    def _get_completed_assignments(obj):
-        return models.TutorSubmissionAssignment.objects.filter(
-            is_done=True,
-            subscription__owner=obj,
-        )
-
-    def get_feedback_created(self, obj):
-        return self._get_completed_assignments(obj).filter(
-            subscription__feedback_stage=models.SubmissionSubscription.FEEDBACK_CREATION  # noqa
-        ).count()
+    def get_feedback_created(self, t):
+        ''' It is required that this field was previously annotated '''
+        return t.feedback_created if hasattr(t, 'feedback_created') else 0
 
-    def get_feedback_validated(self, obj):
-        return self._get_completed_assignments(obj).filter(
-            subscription__feedback_stage=models.SubmissionSubscription.FEEDBACK_VALIDATION  # noqa
-        ).count()
+    def get_feedback_validated(self, t):
+        ''' It is required that this field was previously annotated '''
+        return t.feedback_validated if hasattr(t, 'feedback_validated') else 0
 
     def create(self, validated_data) -> models.UserAccount:
         log.info("Crating tutor from data %s", validated_data)
diff --git a/core/tests/test_custom_subscription_filter.py b/core/tests/test_custom_subscription_filter.py
index 0731815c..4f111626 100644
--- a/core/tests/test_custom_subscription_filter.py
+++ b/core/tests/test_custom_subscription_filter.py
@@ -1,10 +1,9 @@
+from django.conf import settings
 from rest_framework.test import APITestCase
 
+from core.models import Feedback, SubmissionSubscription
 from util.factories import make_test_data
 
-from core.models import SubmissionSubscription, Feedback
-from django.conf import settings
-
 
 class StopAfterFiftyPercent(APITestCase):
 
diff --git a/core/tests/test_tutor_api_endpoints.py b/core/tests/test_tutor_api_endpoints.py
index 72e399cc..95728f80 100644
--- a/core/tests/test_tutor_api_endpoints.py
+++ b/core/tests/test_tutor_api_endpoints.py
@@ -4,17 +4,17 @@
     * POST /tutor/:username/:email create a new tutor and email password
     * GET /tutorlist list of all tutors with their scores
 """
+from django.conf import settings
 from django.contrib.auth import get_user_model
 from rest_framework import status
 from rest_framework.reverse import reverse
 from rest_framework.test import (APIClient, APIRequestFactory, APITestCase,
                                  force_authenticate)
 
-from core.models import SubmissionSubscription, TutorSubmissionAssignment
+from core.models import (Feedback, SubmissionSubscription,
+                         TutorSubmissionAssignment)
 from core.views import TutorApiViewSet
-from util.factories import GradyUserFactory
-
-from django.conf import settings
+from util.factories import GradyUserFactory, make_test_data
 
 NUMBER_OF_TUTORS = 3
 
@@ -49,24 +49,68 @@ class TutorListTests(APITestCase):
 
     @classmethod
     def setUpTestData(cls):
-        cls.factory = APIRequestFactory()
-        cls.user_factory = GradyUserFactory()
+        factory = APIRequestFactory()
+
+        request = factory.get(reverse('tutor-list'))
+        view = TutorApiViewSet.as_view({'get': 'list'})
+
+        data = make_test_data(data_dict={
+            'submission_types': [{
+                'name': '01. Sort this or that',
+                'full_score': 35,
+                'description': 'Very complicated',
+                'solution': 'Trivial!'}],
+            'students': [
+                {'username': 'student01'},
+                {'username': 'student02'}
+            ],
+            'tutors': [
+                {'username': 'tutor01'},
+                {'username': 'tutor02'}
+            ],
+            'reviewers': [
+                {'username': 'reviewer'}
+            ],
+            'submissions': [
+                {
+                    'text': 'function blabl\n'
+                            '   on multi lines\n',
+                    'type': '01. Sort this or that',
+                    'user': 'student01'
+                },
+                {
+                    'text': 'function blabl\n'
+                            '   on multi lines\n',
+                    'type': '01. Sort this or that',
+                    'user': 'student02'
+                }
+            ]}
+        )
 
-    def setUp(self):
-        self.tutor_list = [self.user_factory.make_tutor()
-                           for _ in range(NUMBER_OF_TUTORS)]
-        self.reviewer = self.user_factory.make_reviewer()
-        self.request = self.factory.get(reverse('tutor-list'))
-        self.view = TutorApiViewSet.as_view({'get': 'list'})
+        def feedback_cycle(tutor, stage):
+            subscription = SubmissionSubscription.objects.create(
+                owner=tutor,
+                feedback_stage=stage)
+            assignment = subscription.get_or_create_work_assignment()
+            Feedback.objects.update_or_create(
+                of_submission=assignment.submission,
+                score=35)
 
-        force_authenticate(self.request, user=self.reviewer)
-        self.response = self.view(self.request)
+        tutor01 = data['tutors'][0]
+        tutor02 = data['tutors'][1]
+        reviewer = data['reviewers'][0]
+
+        feedback_cycle(tutor01, SubmissionSubscription.FEEDBACK_CREATION)
+        feedback_cycle(tutor02, SubmissionSubscription.FEEDBACK_VALIDATION)
+
+        force_authenticate(request, user=reviewer)
+        cls.response = view(request)
 
     def test_can_access(self):
         self.assertEqual(self.response.status_code, status.HTTP_200_OK)
 
     def test_get_a_list_of_all_tutors(self):
-        self.assertEqual(len(self.response.data), NUMBER_OF_TUTORS)
+        self.assertEqual(2, len(self.response.data))
 
     def test_feedback_created_count_matches_database(self):
         def verify_fields(tutor_obj):
@@ -88,7 +132,7 @@ class TutorListTests(APITestCase):
                 subscription__feedback_stage=SubmissionSubscription.FEEDBACK_VALIDATION,  # noqa
                 subscription__owner=t
             ).count()
-            return feedback_validated_cnt == tutor_obj['feedback_created']
+            return feedback_validated_cnt == tutor_obj['feedback_validated']
 
         self.assertTrue(all(map(verify_fields, self.response.data)))
 
diff --git a/core/views/common_views.py b/core/views/common_views.py
index 0b4c8432..caa5ca23 100644
--- a/core/views/common_views.py
+++ b/core/views/common_views.py
@@ -5,11 +5,11 @@ import logging
 
 from django.conf import settings
 from django.db.models import Avg
-from rest_framework import generics, mixins, viewsets, status
+from rest_framework import generics, mixins, status, viewsets
 from rest_framework.decorators import api_view, list_route
+from rest_framework.exceptions import PermissionDenied
 from rest_framework.permissions import AllowAny
 from rest_framework.response import Response
-from rest_framework.exceptions import PermissionDenied
 
 from core import models
 from core.models import ExamType, StudentInfo, SubmissionType
@@ -98,7 +98,10 @@ class TutorApiViewSet(
         viewsets.GenericViewSet):
     """ Api endpoint for creating, listing, viewing or deleteing tutors """
     permission_classes = (IsReviewer,)
-    queryset = models.UserAccount.objects.filter(role='Tutor')
+    queryset = models.UserAccount.tutors \
+        .with_feedback_count() \
+        .prefetch_related('subscriptions') \
+        .prefetch_related('subscriptions__assignments')
     serializer_class = TutorSerializer
 
     @list_route(methods=['post'], permission_classes=[AllowAny])
@@ -140,11 +143,18 @@ class StatisticsEndpoint(viewsets.ViewSet):
 class SubmissionViewSet(viewsets.ReadOnlyModelViewSet):
     permission_classes = (IsTutorOrReviewer, )
     serializer_class = SubmissionNoTypeSerializer
+    queryset = models.Submission.objects\
+        .select_related('type')\
+        .select_related('feedback')\
+        .prefetch_related('tests')\
+        .prefetch_related('feedback__feedback_lines')\
+        .prefetch_related('feedback__feedback_lines__of_tutor')\
+        .all()
 
     def get_queryset(self):
         if self.request.user.is_reviewer():
-            return models.Submission.objects.all()
-        else:
-            return models.Submission.objects.filter(
-                assignments__subscription__owner=self.request.user
-            )
+            return self.queryset
+
+        return self.queryset.filter(
+            assignments__subscription__owner=self.request.user
+        )
diff --git a/core/views/export.py b/core/views/export.py
index 81b0f53a..7628c7fe 100644
--- a/core/views/export.py
+++ b/core/views/export.py
@@ -1,9 +1,9 @@
-from core.models import StudentInfo, SubmissionType
-from core.permissions import IsReviewer
-
+from rest_framework.response import Response
 from rest_framework.views import APIView
 from rest_framework_csv import renderers
-from rest_framework.response import Response
+
+from core.models import StudentInfo, SubmissionType
+from core.permissions import IsReviewer
 
 
 class SemicolonCSVRenderer(renderers.CSVRenderer):
diff --git a/core/views/feedback.py b/core/views/feedback.py
index 6735d167..0f681bdb 100644
--- a/core/views/feedback.py
+++ b/core/views/feedback.py
@@ -19,6 +19,8 @@ class FeedbackApiView(
     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'
@@ -72,12 +74,14 @@ class FeedbackApiView(
 
     def get_queryset(self):
         if self.request.user.is_reviewer():
-            return self.queryset.all()
-        else:
-            # only return feedback that the requesting user has worked on
-            return self.queryset.filter(
-                of_submission__assignments__subscription__owner=self.request.user  # noqa
-            )
+            return self.queryset \
+                .prefetch_related('feedback_lines') \
+                .prefetch_related('feedback_lines__of_tutor') \
+                .all()
+
+        return self.queryset.filter(
+            of_submission__assignments__subscription__owner=self.request.user
+        )
 
     def create(self, request, *args, **kwargs):
         serializer = self.get_serializer(data=request.data)
-- 
GitLab