Repository: apluslms/a-plus

Issue:
A+ All results table (course points page) should not include unconfirmed points in the normal official points
The All results table includes unconfirmed points in the normal points, but it should not. It must be possible to separate normal real points from the unconfirmed and unofficial points. It must also be possible to combine normal and unconfirmed points without unofficial points.

The table should have checkboxes for selecting
1. normal points
2. normal with unconfirmed points
3. normal with unofficial points
4. normal with unconfirmed and unofficial

In other words, it should NOT combine the options "unofficial" and "unconfirmed" into a single checkbox. It must be possible to separate unconfirmed points from unofficial.

Unconfirmed points are part of the mandatory feedback feature, which is triggered by the category setting `confirm_the_level` (Mandatory activity).

Related to #918. 


Discussion:
I want to confirm that I have understood the intended behaviour of `confirm_the_level`. If some exercise is in a category with `confirm_the_level` set to true, all submissions to itself and its sibling exercises (they share a parent) are considered unconfirmed until a passing submission is made to it. E.g. if exercises A, B and C are in the same chapter and C's category has `confirm_the_level = True`, all points from A, B and C are unconfirmed until a submission is made to C that gets at least `points_to_pass` points. This doesn't confirm the points of the child exercises of A, B and C (if such exist). Is this correct?

Resolving patch:
diff --git a/exercise/api/csv/views.py b/exercise/api/csv/views.py
--- a/exercise/api/csv/views.py
+++ b/exercise/api/csv/views.py
@@ -1,5 +1,13 @@
-from typing import Any, Dict, Optional, Set, Union
-
+from typing import Any, Dict, Iterable, List, Optional, Set, Union
+
+from django.db.models import (
+    Exists,
+    ExpressionWrapper,
+    F,
+    IntegerField,
+    OuterRef,
+    Q,
+)
 from django.db.models.aggregates import Count
 from django.db.models.query import QuerySet
 from rest_framework import viewsets
@@ -14,6 +22,8 @@
 from lib.api.constants import REGEX_INT_ME
 from course.api.mixins import CourseResourceMixin
 from course.permissions import IsCourseAdminOrUserObjIsSelf
+from exercise.exercise_models import BaseExercise
+from exercise.submission_models import SubmissionQuerySet
 from userprofile.models import UserProfile
 
 from ...cache.points import CachedPoints
@@ -296,6 +306,8 @@ class CourseResultsDataViewSet(NestedViewSetMixin,
     lookup_value_regex = REGEX_INT_ME
     parent_lookup_map = {'course_id': 'enrollment.course_instance.id'}
 
+    point_annotator = "annotate_submitter_points"
+
     def get_queryset(self):
         if self.action == 'list':
             return self.instance.students
@@ -318,17 +330,83 @@ def retrieve(self, request, version=None, course_id=None, user_id=None):
         return self.serialize_profiles(request, [self.get_object()])
 
     # pylint: disable-next=too-many-arguments
-    def get_submissions_query(self, ids, profiles, exclude_list, revealed_ids, show_unofficial):
-        return (
+    def get_submissions_query(
+            self,
+            ids: List[int],
+            profiles: QuerySet[UserProfile],
+            exclude_list: List[str], # Submission.STATUS
+            revealed_ids: Iterable[int],
+            show_unofficial: bool,
+            show_unconfirmed: bool,
+            ) -> SubmissionQuerySet:
+        query = (
             Submission.objects
             .filter(exercise__in=ids, submitters__in=profiles)
             .exclude(status__in=(exclude_list))
+        )
+
+        if not show_unconfirmed:
+            # Select mandatory sibling exercises
+            need_to_confirm = (
+                BaseExercise.objects
+                .annotate(
+                    # ExpressionWrapper is needed due to https://code.djangoproject.com/ticket/31714
+                    outer_parent_id=ExpressionWrapper(
+                        OuterRef("exercise__parent_id"),
+                        output_field=IntegerField(),
+                    )
+                )
+                .filter(
+                    Q(parent_id=F("outer_parent_id"))
+                    # Exercises directly under a module have NULL parents
+                    | Q(parent_id=None, outer_parent_id=None),
+                )
+                .filter(
+                    course_module__course_instance=self.instance,
+                    category__confirm_the_level=True,
+                    course_module_id=OuterRef("exercise__course_module_id"),
+                )
+            )
+            # Select submissions that pass mandatory sibling exercises
+            confirmed = (
+                Submission.objects
+                .annotate(
+                    # ExpressionWrapper is needed due to https://code.djangoproject.com/ticket/31714
+                    outer_parent_id=ExpressionWrapper(
+                        OuterRef("exercise__parent_id"),
+                        output_field=IntegerField(),
+                    )
+                )
+                .filter(
+                    Q(exercise__parent_id=F("outer_parent_id"))
+                    # Exercises directly under a module have NULL parents
+                    | Q(exercise__parent_id=None, outer_parent_id=None),
+                )
+                .filter(
+                    submitters=OuterRef("submitters"),
+                    exercise__category__confirm_the_level=True,
+                    exercise__course_module_id=OuterRef("exercise__course_module_id"),
+                )
+                .passes()
+            )
+            # Exclude unconfirmed submissions: a submission is unconfirmed
+            # if it has at least one mandatory exercise sibling (including the exercise itself)
+            # and none of them have been completed yet
+            query = query.filter(
+                ~Exists(need_to_confirm) | Exists(confirmed)
+            )
+
+        query = (
+            query
             .values('submitters__user_id', 'exercise_id')
             .annotate(count=Count('id'))
-            .annotate_submitter_points('total', revealed_ids, show_unofficial)
-            .order_by()
         )
 
+        # Call annotate_best_submitter_points or annotate_submitter_points depending on the selection
+        query = getattr(query, self.point_annotator)('total', revealed_ids, show_unofficial)
+
+        return query.order_by()
+
     def serialize_profiles(self, request: Request, profiles: QuerySet[UserProfile]) -> Response:
         search_args = self.get_search_args(request)
         _, exercises = self.content.search_entries(**search_args)
@@ -339,7 +417,8 @@ def serialize_profiles(self, request: Request, profiles: QuerySet[UserProfile])
         show_unofficial = request.GET.get('show_unofficial') == 'true'
         if not show_unofficial:
             exclude_list.append(Submission.STATUS.UNOFFICIAL)
-        aggr = self.get_submissions_query(ids, profiles, exclude_list, revealed_ids, show_unofficial)
+        show_unconfirmed = request.GET.get('show_unconfirmed') == 'true'
+        aggr = self.get_submissions_query(ids, profiles, exclude_list, revealed_ids, show_unofficial, show_unconfirmed)
         data,fields = aggregate_points(
             profiles,
             self.instance.taggings.all(),
@@ -369,17 +448,7 @@ class CourseBestResultsDataViewSet(CourseResultsDataViewSet):
     The results are returned as if all exercises used the BEST mode
     and the LAST mode is ignored.
     """
-    # pylint: disable-next=too-many-arguments
-    def get_submissions_query(self, ids, profiles, exclude_list, revealed_ids, show_unofficial):
-        return (
-            Submission.objects
-            .filter(exercise__in=ids, submitters__in=profiles)
-            .exclude(status__in=(exclude_list))
-            .values('submitters__user_id', 'exercise_id')
-            .annotate(count=Count('id'))
-            .annotate_best_submitter_points('total', revealed_ids, show_unofficial)
-            .order_by()
-        )
+    point_annotator = "annotate_best_submitter_points"
 
 
 def int_or_none(value):
diff --git a/exercise/submission_models.py b/exercise/submission_models.py
--- a/exercise/submission_models.py
+++ b/exercise/submission_models.py
@@ -3,7 +3,7 @@
 import logging
 from mimetypes import guess_type
 import os
-from typing import IO, Dict, Iterable, List, Tuple, TYPE_CHECKING
+from typing import IO, Dict, Iterable, List, Tuple, TYPE_CHECKING, Callable
 from urllib.parse import urlparse
 
 from binaryornot.check import is_binary
@@ -36,6 +36,10 @@
 
 
 class SubmissionQuerySet(models.QuerySet):
+    def passes(self) -> "SubmissionQuerySet":
+        """Filter only submissions that pass the exercise"""
+        return self.filter(grade__gte=F("exercise__points_to_pass"))
+
     def annotate_submitter_points(
             self,
             field_name: str = 'total',
@@ -220,6 +224,9 @@ def defer_text_fields(self):
 class SubmissionManager(JWTAccessible["Submission"], models.Manager):
     _queryset_class = SubmissionQuerySet
 
+    # Hints the correct return type for .filter(...)
+    filter: Callable[..., SubmissionQuerySet]
+
     def get_queryset(self):
         return super().get_queryset()\
             .prefetch_related('submitters')


Test patch:
diff --git a/exercise/api/csv/tests.py b/exercise/api/csv/tests.py
new file mode 100644
--- /dev/null
+++ b/exercise/api/csv/tests.py
@@ -0,0 +1,201 @@
+from datetime import timedelta
+
+from django.test import TestCase
+from django.contrib.auth.models import User
+from django.utils import timezone
+
+from course.models import Course, CourseInstance, CourseModule
+from exercise.models import BaseExercise, CourseChapter, LearningObjectCategory
+from exercise.submission_models import Submission
+
+from .views import CourseResultsDataViewSet
+
+class CourseResultsDataViewSetTest(TestCase):
+    def setUp(self):
+        # Make a student
+        self.student = User(username="testUser", first_name="Superb",
+                            last_name="Student", email="test@aplus.com")
+        self.student.set_password("testPassword")
+        self.student.save()
+        self.student_profile = self.student.userprofile
+        self.student_profile.student_id = "12345X"
+        self.student_profile.save()
+
+        self.student2 = User(username="testUser2", first_name="Superb",
+                            last_name="Student", email="test@aplus.com")
+        self.student2.set_password("testPassword")
+        self.student2.save()
+        self.student_profile2 = self.student2.userprofile
+        self.student_profile2.student_id = "12345Y"
+        self.student_profile2.save()
+
+        # Make a course and course instance
+        self.course = Course.objects.create(
+            name="test course",
+            code="123456",
+            url="Course-Url"
+        )
+
+        self.today = timezone.now()
+        self.tomorrow = self.today + timedelta(days=1)
+
+        self.course_instance1 = CourseInstance.objects.create(
+            instance_name="Fall 2011 day 1",
+            starting_time=self.today,
+            ending_time=self.tomorrow,
+            course=self.course,
+            url="T-00.1000_d1"
+        )
+
+        # Add learning object categories to course
+        self.chapter_category = LearningObjectCategory.objects.create(
+            name="chapter",
+            course_instance=self.course_instance1
+        )
+        self.learning_object_category1 = LearningObjectCategory.objects.create(
+            name="test category 1",
+            course_instance=self.course_instance1
+        )
+        self.mandatory_category1 = LearningObjectCategory.objects.create(
+            name="mandatory category 1",
+            confirm_the_level=True,
+            course_instance=self.course_instance1
+        )
+
+        self.module1 = CourseModule.objects.create(
+            name="module1",
+            url="module1",
+            course_instance=self.course_instance1,
+        )
+
+        # Add learning objects to module1
+        self.learning_object1 = BaseExercise.objects.create(
+            name="learning object 1",
+            url="lo1",
+            course_module=self.module1,
+            category=self.learning_object_category1,
+        )
+        self.mandatory_learning_object1 = BaseExercise.objects.create(
+            name="mandatory learning object 1",
+            url="mlo1",
+            course_module=self.module1,
+            category=self.mandatory_category1,
+            points_to_pass=1,
+        )
+
+        # Add learning objects to module1
+        self.chapter1 = CourseChapter.objects.create(
+            name="chapter 1",
+            url="c1",
+            course_module=self.module1,
+            category=self.chapter_category,
+        )
+        self.c1_learning_object1 = BaseExercise.objects.create(
+            name="c1 learning object 1",
+            url="c1lo1",
+            course_module=self.module1,
+            parent=self.chapter1,
+            category=self.learning_object_category1,
+        )
+        self.c1_mandatory_learning_object1 = BaseExercise.objects.create(
+            name="c1 mandatory learning object 1",
+            url="c1mlo1",
+            course_module=self.module1,
+            parent=self.chapter1,
+            category=self.mandatory_category1,
+            points_to_pass=1,
+        )
+
+        # Add a student to course instance
+        self.course_instance1.enroll_student(self.student_profile.user)
+        self.course_instance1.enroll_student(self.student_profile2.user)
+
+    def test_get_submissions_query_unconfirmed_points(self):
+        def query(unconfirmed: bool):
+            qset = view.get_submissions_query(
+                ids,
+                self.course_instance1.students,
+                [],
+                ids,
+                True,
+                unconfirmed,
+            )
+            return {(row["submitters__user_id"], row["exercise_id"], row["count"]) for row in qset}
+
+        def create_submission(user_profile, exercise, **kwargs):
+            nonlocal submissions
+            sub = Submission.objects.create(exercise=exercise, **kwargs)
+            sub.submitters.add(user_profile)
+
+            submissions.setdefault((user_profile.user_id, exercise.id), [0, False])
+            submissions[(user_profile.user_id, exercise.id)][0] += 1
+
+            if exercise.category.confirm_the_level and sub.grade >= exercise.points_to_pass:
+                if exercise.parent is not None:
+                    children = exercise.parent.children.all()
+                else:
+                    children = [lo for lo in exercise.course_module.learning_objects.all() if lo.parent is None]
+
+                for c in children:
+                    if (user_profile.user_id, c.id) in submissions:
+                        submissions[(user_profile.user_id, c.id)][1] = True
+
+            return (user_profile.user_id, exercise.id, 1)
+
+        def all_submissions():
+            nonlocal submissions
+            return {
+                (user_id, ex_id, count)
+                for (user_id, ex_id), (count, confirmed) in submissions.items()
+            }
+
+        def confirmed_submissions():
+            nonlocal submissions
+            return {
+                (user_id, ex_id, count)
+                for (user_id, ex_id), (count, confirmed) in submissions.items()
+                if confirmed
+            }
+
+        view = CourseResultsDataViewSet()
+        view.instance = self.course_instance1
+
+        submissions = {}
+
+        ids = [
+            self.learning_object1.id,
+            self.mandatory_learning_object1.id,
+            self.c1_learning_object1.id,
+            self.c1_mandatory_learning_object1.id,
+        ]
+
+        create_submission(self.student_profile, exercise=self.learning_object1)
+        create_submission(self.student_profile, exercise=self.c1_learning_object1)
+        create_submission(self.student_profile2, exercise=self.learning_object1)
+        create_submission(self.student_profile2, exercise=self.c1_learning_object1)
+
+        self.assertEqual(query(True), all_submissions())
+        self.assertEqual(query(False), confirmed_submissions())
+
+
+        create_submission(self.student_profile, exercise=self.mandatory_learning_object1, grade=2)
+
+        self.assertEqual(query(True), all_submissions())
+        self.assertEqual(query(False), confirmed_submissions())
+
+
+        create_submission(self.student_profile, exercise=self.c1_mandatory_learning_object1, grade=2)
+
+        self.assertEqual(query(True), all_submissions())
+        self.assertEqual(query(False), confirmed_submissions())
+
+
+        create_submission(self.student_profile2, exercise=self.c1_mandatory_learning_object1, grade=0)
+
+        self.assertEqual(query(True), all_submissions())
+        self.assertEqual(query(False), confirmed_submissions())
+
+        create_submission(self.student_profile2, exercise=self.c1_mandatory_learning_object1, grade=2)
+
+        self.assertEqual(query(True), all_submissions())
+        self.assertEqual(query(False), confirmed_submissions())
\ No newline at end of file