Repository: learningequality/kolibri

Issue:
wrong average score in the coach reports after the first learner of a quiz submits it
<!--
Instructions:
 * Fill out the sections below, replace …'s with information about your issue
 * Use the 'preview' function above this text box to verify formatting before submitting
-->

## Observed behavior
<!--
Description of the behavior that was observed, including screenshots or other references when applicable
-->

when the first quiz taker submits a quiz , the quiz score showing to the quiz taker (learner) after quiz submission is correct  but the initial average score (reports view in coach account) of that quiz for the whole class is Wrong .

### what i exactly  did is the following :  

- created a quiz from the coach account with 5 quesions
- (in another session eg.Incognito mode ) took that quiz  as a learner and answered all the 5 questions correct
- ( learner account ) the result score for that quiz is 100% (5/5) as expected 
- (coach account ) without reloading the page, the average score for that quiz in the coach reports is 20% ! whic is wrong  but  refreshing  the page it shows 100% as expected .

### what does that mean ?
It means an important thing (to lead the person working on this issue in the right direction)
The quiz answers  got saved in the database normally and  correctly as expected that's why when you refresh the page you see the expected average score , so i think it has something to do with the notifications pushed to the coach.

### related files 
/kolibri/core/notifications/api.py  line 553
examine (quiz_completed_notification) function 


## Expected behavior
<!--
Description of what behavior was expected but did not occur
-->

coah should sees correct average score (initially the same as the score showed to the learner) after the first learner who took the quiz  submits the quiz


## Steps to reproduce
<!--
Precise steps that someone else can follow in order to see this behavior
-->

1. login as a coach, make a quiz and start it (try a simple one where you can answer correctly)
2. from another window (Incognito mode) login as a learner , take this quiz
3. answer the questions and then submit
4. look at  the other window (logged in as a coach) without refreshing the page
5. observe the expected avg score for that quiz  in the reports view (again without loading the page)   


## Context
<!--
Tell us about your environment, including:
 * Kolibri version
 * Operating system
 * Browser
-->

### noticed the same issue on both environments :
 
Production evironment)( following  kolibri user guide )
Kolibri 0.15.1
Windows 10
Chrome 99.0.4844.74
___________________
Developemnt environment (following Kolibri developer documentation )
Kolibri 0.16.0a4.dev0+git.88.g98506819
Ubuntu 20.04
Chrome 100.0.4896.60




Discussion:
I can replicate this with 0.15.6 on Ubuntu 18.04:

![quiz-0 15 6](https://user-images.githubusercontent.com/1457929/181648176-06461f5a-8f46-45db-8d06-b41abcf6ad4a.gif)

so this behaviour is not related to the environment used.

also i referenced a function above that me be relevant

Resolving patch:
diff --git a/kolibri/core/logger/utils/quiz.py b/kolibri/core/logger/utils/quiz.py
new file mode 100644
--- /dev/null
+++ b/kolibri/core/logger/utils/quiz.py
@@ -0,0 +1,36 @@
+from django.db.models import Count
+from django.db.models import OuterRef
+from django.db.models import Subquery
+from django.db.models import Sum
+
+from kolibri.core.logger.models import AttemptLog
+
+
+def annotate_response_summary(queryset):
+    """
+    Accepts a MasteryLog queryset as the only argument.
+    Returns a queryset that has num_correct and num_answered
+    annotations on the queryset.
+
+    Only useful for MasteryLogs generated for quizzes.
+    """
+    return queryset.annotate(
+        num_correct=Subquery(
+            AttemptLog.objects.filter(masterylog=OuterRef("id"))
+            .order_by()
+            .values_list("item")
+            .distinct()
+            .values("masterylog")
+            .annotate(total_correct=Sum("correct"))
+            .values("total_correct")
+        ),
+        num_answered=Subquery(
+            AttemptLog.objects.filter(masterylog=OuterRef("id"))
+            .order_by()
+            .values_list("item")
+            .distinct()
+            .values("masterylog")
+            .annotate(total_complete=Count("id"))
+            .values("total_complete")
+        ),
+    )
diff --git a/kolibri/core/notifications/api.py b/kolibri/core/notifications/api.py
--- a/kolibri/core/notifications/api.py
+++ b/kolibri/core/notifications/api.py
@@ -20,6 +20,7 @@
 from kolibri.core.logger.models import ExamAttemptLog
 from kolibri.core.logger.models import ExamLog
 from kolibri.core.logger.models import MasteryLog
+from kolibri.core.logger.utils.quiz import annotate_response_summary
 from kolibri.core.query import annotate_array_aggregate
 
 
@@ -565,11 +566,11 @@ def quiz_completed_notification(masterylog, quiz_id):
         Exam.objects.filter(id=quiz_id).values_list("collection_id", flat=True).first()
     )
 
-    attempts = (
-        masterylog.attemptlogs.values_list("item")
-        .order_by("completion_timestamp")
-        .distinct()
-        .values_list("correct", flat=True)
+    response_data = (
+        annotate_response_summary(MasteryLog.objects.filter(id=masterylog.id))
+        .values("num_correct", "num_answered")
+        .first()
+        or {}
     )
 
     notification = create_notification(
@@ -579,8 +580,8 @@ def quiz_completed_notification(masterylog, quiz_id):
         collection_id,
         assignment_collections=assigned_collections,
         quiz_id=quiz_id,
-        quiz_num_correct=sum(attempts) or 0,
-        quiz_num_answered=len(attempts) or 0,
+        quiz_num_correct=response_data.get("num_correct", 0),
+        quiz_num_answered=response_data.get("num_answered", 0),
         timestamp=masterylog.completion_timestamp,
     )
 
diff --git a/kolibri/plugins/coach/class_summary_api.py b/kolibri/plugins/coach/class_summary_api.py
--- a/kolibri/plugins/coach/class_summary_api.py
+++ b/kolibri/plugins/coach/class_summary_api.py
@@ -2,10 +2,7 @@
 from django.db.models import Count
 from django.db.models import F
 from django.db.models import Max
-from django.db.models import OuterRef
 from django.db.models import Q
-from django.db.models import Subquery
-from django.db.models import Sum
 from django.db.utils import OperationalError
 from django.shortcuts import get_object_or_404
 from le_utils.constants import content_kinds
@@ -22,6 +19,7 @@
 from kolibri.core.exams.models import Exam
 from kolibri.core.lessons.models import Lesson
 from kolibri.core.logger import models as logger_models
+from kolibri.core.logger.utils.quiz import annotate_response_summary
 from kolibri.core.notifications.models import LearnerProgressNotification
 from kolibri.core.notifications.models import NotificationEventType
 from kolibri.core.query import annotate_array_aggregate
@@ -157,27 +155,8 @@ def serialize_exam_status(queryset):
     return list(
         map(
             _map_exam_status,
-            queryset.annotate(
-                last_activity=Max("attemptlogs__end_timestamp"),
-                num_correct=Subquery(
-                    logger_models.AttemptLog.objects.filter(masterylog=OuterRef("id"))
-                    .order_by()
-                    .values_list("item")
-                    .distinct()
-                    .values("masterylog")
-                    .annotate(total_correct=Sum("correct"))
-                    .values("total_correct")
-                ),
-                num_answered=Subquery(
-                    logger_models.AttemptLog.objects.filter(masterylog=OuterRef("id"))
-                    .order_by()
-                    .values_list("item")
-                    .distinct()
-                    .values("masterylog")
-                    .annotate(total_complete=Count("id"))
-                    .values("total_complete")
-                ),
-            )
+            annotate_response_summary(queryset)
+            .annotate(last_activity=Max("attemptlogs__end_timestamp"))
             .values(
                 "summarylog__content_id",
                 "complete",


Test patch:
diff --git a/kolibri/core/notifications/test/test_api.py b/kolibri/core/notifications/test/test_api.py
--- a/kolibri/core/notifications/test/test_api.py
+++ b/kolibri/core/notifications/test/test_api.py
@@ -250,7 +250,10 @@ def test_quiz_completed_notification(self, save_notifications):
             user=self.user1,
             mastery_level=-1,
         )
-
+        # Make sure we test with greater than 2 attempts
+        # as otherwise a regression is possible where
+        # the unique values of correct are counted
+        # rather than the unique attempt items.
         AttemptLog.objects.create(
             masterylog=masterylog_quiz,
             sessionlog=sessionlog_quiz,
@@ -260,6 +263,24 @@ def test_quiz_completed_notification(self, save_notifications):
             user=self.user1,
             correct=0,
         )
+        AttemptLog.objects.create(
+            masterylog=masterylog_quiz,
+            sessionlog=sessionlog_quiz,
+            item="test1",
+            start_timestamp=local_now(),
+            end_timestamp=local_now(),
+            user=self.user1,
+            correct=1,
+        )
+        AttemptLog.objects.create(
+            masterylog=masterylog_quiz,
+            sessionlog=sessionlog_quiz,
+            item="test2",
+            start_timestamp=local_now(),
+            end_timestamp=local_now(),
+            user=self.user1,
+            correct=1,
+        )
         quiz_completed_notification(masterylog_quiz, self.exam.id)
         assert save_notifications.called is False
         masterylog_quiz.complete = True
@@ -269,8 +290,8 @@ def test_quiz_completed_notification(self, save_notifications):
         notification = save_notifications.call_args[0][0][0]
         assert notification.notification_object == NotificationObjectType.Quiz
         assert notification.notification_event == NotificationEventType.Completed
-        assert notification.quiz_num_answered == 1
-        assert notification.quiz_num_correct == 0
+        assert notification.quiz_num_answered == 3
+        assert notification.quiz_num_correct == 2
 
     @patch("kolibri.core.notifications.api.save_notifications")
     def test_quiz_started_notification(self, save_notifications):