We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
When starting Exams, Notifications and Attempt logs can be out of sync

### Observed behavior

1. As a learner, start an exam, but do not do any of the questions
1. After some time, a Notification will be recorded for the exam with a status of "Started"
1. However, querying the class summary API, the 'exam learner status' for that exam will have a status of "NotStarted".

This can lead to an inconsistency, where the Notification will update the in-memory classSummary data and cause the dashboard and reports to show that 1 learner has "started" an exam, but if you were to refresh (and get the on-server class summary data without the updating notification), it will revert to showing 0 learners starting the exam.

### Expected behavior

Since, the UI intends to use Notifications to patch class summary in real time, the two notions of "Exam Started" should match to avoid situations like the on described above.


### User-facing consequences

inconsistent / fluctuating values in reports


### Steps to reproduce
<!--
Precise steps that someone else can follow in order to see this behavior
-->

…

### Context

0.12.0 a 7

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `kolibri/plugins/coach/class_summary_api.py`
Content:
```
1 from django.db.models import Max
2 from django.db.models import Sum
3 from django.shortcuts import get_object_or_404
4 from rest_framework import serializers
5 from rest_framework import viewsets
6 from rest_framework.response import Response
7 
8 from kolibri.core.auth import models as auth_models
9 from kolibri.core.content.models import ContentNode
10 from kolibri.core.exams.models import Exam
11 from kolibri.core.lessons.models import Lesson
12 from kolibri.core.logger import models as logger_models
13 from kolibri.core.notifications.models import LearnerProgressNotification
14 from kolibri.core.notifications.models import NotificationEventType
15 
16 
17 # Intended to match  NotificationEventType
18 NOT_STARTED = "NotStarted"
19 STARTED = "Started"
20 HELP_NEEDED = "HelpNeeded"
21 COMPLETED = "Completed"
22 
23 
24 def content_status_serializer(lesson_data, learners_data, classroom):
25 
26     # First generate a unique set of content node ids from all the lessons
27     lesson_node_ids = set()
28     for lesson in lesson_data:
29         lesson_node_ids |= set(lesson.get("node_ids"))
30 
31     # Now create a map of content_id to node_id so that we can map between lessons, and notifications
32     # which use the node id, and summary logs, which use content_id
33     content_map = {n[0]: n[1] for n in ContentNode.objects.filter(id__in=lesson_node_ids).values_list("content_id", "id")}
34 
35     # Get all the values we need from the summary logs to be able to summarize current status on the
36     # relevant content items.
37     content_log_values = logger_models.ContentSummaryLog.objects.filter(
38         content_id__in=set(content_map.keys()), user__in=[learner["id"] for learner in learners_data]
39     ).values("user_id", "content_id", "end_timestamp", "time_spent", "progress")
40 
41     # In order to make the lookup speedy, generate a unique key for each user/node that we find
42     # listed in the needs help notifications that are relevant. We can then just check
43     # existence of this key in the set in order to see whether this user has been flagged as needing
44     # help.
45     lookup_key = "{user_id}-{node_id}"
46     needs_help = {
47         lookup_key.format(user_id=n[0], node_id=n[1]): n[2] for n in LearnerProgressNotification.objects.filter(
48             classroom_id=classroom.id,
49             notification_event=NotificationEventType.Help,
50             lesson_id__in=[lesson["id"] for lesson in lesson_data],
51         ).values_list("user_id", "contentnode_id", "timestamp")
52     }
53 
54     # In case a previously flagged learner has since completed an exercise, check all the completed
55     # notifications also
56     completed = {
57         lookup_key.format(user_id=n[0], node_id=n[1]): n[2] for n in LearnerProgressNotification.objects.filter(
58             classroom_id=classroom.id,
59             notification_event=NotificationEventType.Completed,
60             lesson_id__in=[lesson["id"] for lesson in lesson_data],
61         ).values_list("user_id", "contentnode_id", "timestamp")
62     }
63 
64     def get_status(log):
65         """
66         Read the dict from a content summary log values query and return the status
67         In the case that we have found a needs help notification for the user and content node
68         in question, return that they need help, otherwise return status based on their
69         current progress.
70         """
71         content_id = log["content_id"]
72         if content_id in content_map:
73             # Don't try to lookup anything if we don't know the content_id
74             # node_id mapping - might happen if a channel has since been deleted
75             key = lookup_key.format(user_id=log["user_id"], node_id=content_map[content_id])
76             if key in needs_help:
77                 # Now check if we have not already registered completion of the content node
78                 # or if we have and the timestamp is earlier than that on the needs_help event
79                 if key not in completed or completed[key] < needs_help[key]:
80                     return HELP_NEEDED
81         if log["progress"] == 1:
82             return COMPLETED
83         elif log["progress"] == 0:
84             return NOT_STARTED
85         return STARTED
86 
87     def map_content_logs(log):
88         """
89         Parse the content logs to return objects in the expected format.
90         """
91         return {
92             "learner_id": log["user_id"],
93             "content_id": log["content_id"],
94             "status": get_status(log),
95             "last_activity": log["end_timestamp"],
96             "time_spent": log["time_spent"],
97         }
98 
99     return map(map_content_logs, content_log_values)
100 
101 
102 class ExamStatusSerializer(serializers.ModelSerializer):
103     status = serializers.SerializerMethodField()
104     exam_id = serializers.PrimaryKeyRelatedField(source="exam", read_only=True)
105     learner_id = serializers.PrimaryKeyRelatedField(source="user", read_only=True)
106     last_activity = serializers.CharField()
107     num_correct = serializers.SerializerMethodField()
108 
109     def get_status(self, exam_log):
110         if exam_log.closed:
111             return COMPLETED
112         elif exam_log.attemptlogs.values_list("item").count() > 0:
113             return STARTED
114         return NOT_STARTED
115 
116     def get_num_correct(self, exam_log):
117         return (
118             exam_log.attemptlogs.values_list('item')
119             .order_by('completion_timestamp')
120             .distinct()
121             .aggregate(Sum('correct'))
122             .get('correct__sum')
123         )
124 
125     class Meta:
126         model = logger_models.ExamLog
127         fields = ("exam_id", "learner_id", "status", "last_activity", "num_correct")
128 
129 
130 class GroupSerializer(serializers.ModelSerializer):
131     member_ids = serializers.SerializerMethodField()
132 
133     def get_member_ids(self, group):
134         return group.get_members().values_list("id", flat=True)
135 
136     class Meta:
137         model = auth_models.LearnerGroup
138         fields = ("id", "name", "member_ids")
139 
140 
141 class UserSerializer(serializers.ModelSerializer):
142     name = serializers.CharField(source="full_name")
143 
144     class Meta:
145         model = auth_models.FacilityUser
146         fields = ("id", "name", "username")
147 
148 
149 class LessonNodeIdsField(serializers.Field):
150     def to_representation(self, values):
151         return [value["contentnode_id"] for value in values]
152 
153 
154 class LessonAssignmentsField(serializers.RelatedField):
155     def to_representation(self, assignment):
156         return assignment.collection.id
157 
158 
159 class LessonSerializer(serializers.ModelSerializer):
160     active = serializers.BooleanField(source="is_active")
161     node_ids = LessonNodeIdsField(default=[], source="resources")
162 
163     # classrooms are in here, and filtered out later
164     groups = LessonAssignmentsField(
165         many=True, read_only=True, source="lesson_assignments"
166     )
167 
168     class Meta:
169         model = Lesson
170         fields = ("id", "title", "active", "node_ids", "groups")
171 
172 
173 class ExamQuestionSourcesField(serializers.Field):
174     def to_representation(self, values):
175         return values
176 
177 
178 class ExamAssignmentsField(serializers.RelatedField):
179     def to_representation(self, assignment):
180         return assignment.collection.id
181 
182 
183 class ExamSerializer(serializers.ModelSerializer):
184 
185     question_sources = ExamQuestionSourcesField(default=[])
186 
187     # classes are in here, and filtered out later
188     groups = ExamAssignmentsField(many=True, read_only=True, source="assignments")
189 
190     class Meta:
191         model = Exam
192         fields = ("id", "title", "active", "question_sources", "groups")
193 
194 
195 class ContentSerializer(serializers.ModelSerializer):
196     node_id = serializers.CharField(source="id")
197 
198     class Meta:
199         model = ContentNode
200         fields = ("node_id", "content_id", "title", "kind")
201 
202 
203 def data(Serializer, queryset):
204     return Serializer(queryset, many=True).data
205 
206 
207 class ClassSummaryViewSet(viewsets.ViewSet):
208     def retrieve(self, request, pk):
209         classroom = get_object_or_404(auth_models.Classroom, id=pk)
210         query_learners = classroom.get_members()
211         query_lesson = Lesson.objects.filter(collection=pk)
212         query_exams = Exam.objects.filter(collection=pk)
213         query_exam_logs = logger_models.ExamLog.objects.filter(
214             exam__in=query_exams
215         ).annotate(last_activity=Max("attemptlogs__end_timestamp"))
216 
217         lesson_data = data(LessonSerializer, query_lesson)
218         exam_data = data(ExamSerializer, query_exams)
219 
220         # filter classes out of exam assignments
221         for exam in exam_data:
222             exam["groups"] = [g for g in exam["groups"] if g != pk]
223 
224         # filter classes out of lesson assignments
225         for lesson in lesson_data:
226             lesson["groups"] = [g for g in lesson["groups"] if g != pk]
227 
228         all_node_ids = set()
229         for lesson in lesson_data:
230             all_node_ids |= set(lesson.get("node_ids"))
231         for exam in exam_data:
232             exam_node_ids = [question['exercise_id'] for question in exam.get("question_sources")]
233             all_node_ids |= set(exam_node_ids)
234 
235         query_content = ContentNode.objects.filter(id__in=all_node_ids)
236 
237         learners_data = data(UserSerializer, query_learners)
238 
239         output = {
240             "id": pk,
241             "name": classroom.name,
242             "coaches": data(UserSerializer, classroom.get_coaches()),
243             "learners": learners_data,
244             "groups": data(GroupSerializer, classroom.get_learner_groups()),
245             "exams": exam_data,
246             "exam_learner_status": data(ExamStatusSerializer, query_exam_logs),
247             "content": data(ContentSerializer, query_content),
248             "content_learner_status": content_status_serializer(lesson_data, learners_data, classroom),
249             "lessons": lesson_data,
250         }
251 
252         return Response(output)
253
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

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
@@ -108,9 +108,8 @@
     def get_status(self, exam_log):
         if exam_log.closed:
             return COMPLETED
-        elif exam_log.attemptlogs.values_list("item").count() > 0:
+        else:
             return STARTED
-        return NOT_STARTED
 
     def get_num_correct(self, exam_log):
         return (

{"golden_diff": "diff --git a/kolibri/plugins/coach/class_summary_api.py b/kolibri/plugins/coach/class_summary_api.py\n--- a/kolibri/plugins/coach/class_summary_api.py\n+++ b/kolibri/plugins/coach/class_summary_api.py\n@@ -108,9 +108,8 @@\n     def get_status(self, exam_log):\n         if exam_log.closed:\n             return COMPLETED\n-        elif exam_log.attemptlogs.values_list(\"item\").count() > 0:\n+        else:\n             return STARTED\n-        return NOT_STARTED\n \n     def get_num_correct(self, exam_log):\n         return (\n", "issue": "When starting Exams, Notifications and Attempt logs can be out of sync\n\r\n### Observed behavior\r\n\r\n1. As a learner, start an exam, but do not do any of the questions\r\n1. After some time, a Notification will be recorded for the exam with a status of \"Started\"\r\n1. However, querying the class summary API, the 'exam learner status' for that exam will have a status of \"NotStarted\".\r\n\r\nThis can lead to an inconsistency, where the Notification will update the in-memory classSummary data and cause the dashboard and reports to show that 1 learner has \"started\" an exam, but if you were to refresh (and get the on-server class summary data without the updating notification), it will revert to showing 0 learners starting the exam.\r\n\r\n### Expected behavior\r\n\r\nSince, the UI intends to use Notifications to patch class summary in real time, the two notions of \"Exam Started\" should match to avoid situations like the on described above.\r\n\r\n\r\n### User-facing consequences\r\n\r\ninconsistent / fluctuating values in reports\r\n\r\n\r\n### Steps to reproduce\r\n<!--\r\nPrecise steps that someone else can follow in order to see this behavior\r\n-->\r\n\r\n\u2026\r\n\r\n### Context\r\n\r\n0.12.0 a 7\n", "before_files": [{"content": "from django.db.models import Max\nfrom django.db.models import Sum\nfrom django.shortcuts import get_object_or_404\nfrom rest_framework import serializers\nfrom rest_framework import viewsets\nfrom rest_framework.response import Response\n\nfrom kolibri.core.auth import models as auth_models\nfrom kolibri.core.content.models import ContentNode\nfrom kolibri.core.exams.models import Exam\nfrom kolibri.core.lessons.models import Lesson\nfrom kolibri.core.logger import models as logger_models\nfrom kolibri.core.notifications.models import LearnerProgressNotification\nfrom kolibri.core.notifications.models import NotificationEventType\n\n\n# Intended to match  NotificationEventType\nNOT_STARTED = \"NotStarted\"\nSTARTED = \"Started\"\nHELP_NEEDED = \"HelpNeeded\"\nCOMPLETED = \"Completed\"\n\n\ndef content_status_serializer(lesson_data, learners_data, classroom):\n\n    # First generate a unique set of content node ids from all the lessons\n    lesson_node_ids = set()\n    for lesson in lesson_data:\n        lesson_node_ids |= set(lesson.get(\"node_ids\"))\n\n    # Now create a map of content_id to node_id so that we can map between lessons, and notifications\n    # which use the node id, and summary logs, which use content_id\n    content_map = {n[0]: n[1] for n in ContentNode.objects.filter(id__in=lesson_node_ids).values_list(\"content_id\", \"id\")}\n\n    # Get all the values we need from the summary logs to be able to summarize current status on the\n    # relevant content items.\n    content_log_values = logger_models.ContentSummaryLog.objects.filter(\n        content_id__in=set(content_map.keys()), user__in=[learner[\"id\"] for learner in learners_data]\n    ).values(\"user_id\", \"content_id\", \"end_timestamp\", \"time_spent\", \"progress\")\n\n    # In order to make the lookup speedy, generate a unique key for each user/node that we find\n    # listed in the needs help notifications that are relevant. We can then just check\n    # existence of this key in the set in order to see whether this user has been flagged as needing\n    # help.\n    lookup_key = \"{user_id}-{node_id}\"\n    needs_help = {\n        lookup_key.format(user_id=n[0], node_id=n[1]): n[2] for n in LearnerProgressNotification.objects.filter(\n            classroom_id=classroom.id,\n            notification_event=NotificationEventType.Help,\n            lesson_id__in=[lesson[\"id\"] for lesson in lesson_data],\n        ).values_list(\"user_id\", \"contentnode_id\", \"timestamp\")\n    }\n\n    # In case a previously flagged learner has since completed an exercise, check all the completed\n    # notifications also\n    completed = {\n        lookup_key.format(user_id=n[0], node_id=n[1]): n[2] for n in LearnerProgressNotification.objects.filter(\n            classroom_id=classroom.id,\n            notification_event=NotificationEventType.Completed,\n            lesson_id__in=[lesson[\"id\"] for lesson in lesson_data],\n        ).values_list(\"user_id\", \"contentnode_id\", \"timestamp\")\n    }\n\n    def get_status(log):\n        \"\"\"\n        Read the dict from a content summary log values query and return the status\n        In the case that we have found a needs help notification for the user and content node\n        in question, return that they need help, otherwise return status based on their\n        current progress.\n        \"\"\"\n        content_id = log[\"content_id\"]\n        if content_id in content_map:\n            # Don't try to lookup anything if we don't know the content_id\n            # node_id mapping - might happen if a channel has since been deleted\n            key = lookup_key.format(user_id=log[\"user_id\"], node_id=content_map[content_id])\n            if key in needs_help:\n                # Now check if we have not already registered completion of the content node\n                # or if we have and the timestamp is earlier than that on the needs_help event\n                if key not in completed or completed[key] < needs_help[key]:\n                    return HELP_NEEDED\n        if log[\"progress\"] == 1:\n            return COMPLETED\n        elif log[\"progress\"] == 0:\n            return NOT_STARTED\n        return STARTED\n\n    def map_content_logs(log):\n        \"\"\"\n        Parse the content logs to return objects in the expected format.\n        \"\"\"\n        return {\n            \"learner_id\": log[\"user_id\"],\n            \"content_id\": log[\"content_id\"],\n            \"status\": get_status(log),\n            \"last_activity\": log[\"end_timestamp\"],\n            \"time_spent\": log[\"time_spent\"],\n        }\n\n    return map(map_content_logs, content_log_values)\n\n\nclass ExamStatusSerializer(serializers.ModelSerializer):\n    status = serializers.SerializerMethodField()\n    exam_id = serializers.PrimaryKeyRelatedField(source=\"exam\", read_only=True)\n    learner_id = serializers.PrimaryKeyRelatedField(source=\"user\", read_only=True)\n    last_activity = serializers.CharField()\n    num_correct = serializers.SerializerMethodField()\n\n    def get_status(self, exam_log):\n        if exam_log.closed:\n            return COMPLETED\n        elif exam_log.attemptlogs.values_list(\"item\").count() > 0:\n            return STARTED\n        return NOT_STARTED\n\n    def get_num_correct(self, exam_log):\n        return (\n            exam_log.attemptlogs.values_list('item')\n            .order_by('completion_timestamp')\n            .distinct()\n            .aggregate(Sum('correct'))\n            .get('correct__sum')\n        )\n\n    class Meta:\n        model = logger_models.ExamLog\n        fields = (\"exam_id\", \"learner_id\", \"status\", \"last_activity\", \"num_correct\")\n\n\nclass GroupSerializer(serializers.ModelSerializer):\n    member_ids = serializers.SerializerMethodField()\n\n    def get_member_ids(self, group):\n        return group.get_members().values_list(\"id\", flat=True)\n\n    class Meta:\n        model = auth_models.LearnerGroup\n        fields = (\"id\", \"name\", \"member_ids\")\n\n\nclass UserSerializer(serializers.ModelSerializer):\n    name = serializers.CharField(source=\"full_name\")\n\n    class Meta:\n        model = auth_models.FacilityUser\n        fields = (\"id\", \"name\", \"username\")\n\n\nclass LessonNodeIdsField(serializers.Field):\n    def to_representation(self, values):\n        return [value[\"contentnode_id\"] for value in values]\n\n\nclass LessonAssignmentsField(serializers.RelatedField):\n    def to_representation(self, assignment):\n        return assignment.collection.id\n\n\nclass LessonSerializer(serializers.ModelSerializer):\n    active = serializers.BooleanField(source=\"is_active\")\n    node_ids = LessonNodeIdsField(default=[], source=\"resources\")\n\n    # classrooms are in here, and filtered out later\n    groups = LessonAssignmentsField(\n        many=True, read_only=True, source=\"lesson_assignments\"\n    )\n\n    class Meta:\n        model = Lesson\n        fields = (\"id\", \"title\", \"active\", \"node_ids\", \"groups\")\n\n\nclass ExamQuestionSourcesField(serializers.Field):\n    def to_representation(self, values):\n        return values\n\n\nclass ExamAssignmentsField(serializers.RelatedField):\n    def to_representation(self, assignment):\n        return assignment.collection.id\n\n\nclass ExamSerializer(serializers.ModelSerializer):\n\n    question_sources = ExamQuestionSourcesField(default=[])\n\n    # classes are in here, and filtered out later\n    groups = ExamAssignmentsField(many=True, read_only=True, source=\"assignments\")\n\n    class Meta:\n        model = Exam\n        fields = (\"id\", \"title\", \"active\", \"question_sources\", \"groups\")\n\n\nclass ContentSerializer(serializers.ModelSerializer):\n    node_id = serializers.CharField(source=\"id\")\n\n    class Meta:\n        model = ContentNode\n        fields = (\"node_id\", \"content_id\", \"title\", \"kind\")\n\n\ndef data(Serializer, queryset):\n    return Serializer(queryset, many=True).data\n\n\nclass ClassSummaryViewSet(viewsets.ViewSet):\n    def retrieve(self, request, pk):\n        classroom = get_object_or_404(auth_models.Classroom, id=pk)\n        query_learners = classroom.get_members()\n        query_lesson = Lesson.objects.filter(collection=pk)\n        query_exams = Exam.objects.filter(collection=pk)\n        query_exam_logs = logger_models.ExamLog.objects.filter(\n            exam__in=query_exams\n        ).annotate(last_activity=Max(\"attemptlogs__end_timestamp\"))\n\n        lesson_data = data(LessonSerializer, query_lesson)\n        exam_data = data(ExamSerializer, query_exams)\n\n        # filter classes out of exam assignments\n        for exam in exam_data:\n            exam[\"groups\"] = [g for g in exam[\"groups\"] if g != pk]\n\n        # filter classes out of lesson assignments\n        for lesson in lesson_data:\n            lesson[\"groups\"] = [g for g in lesson[\"groups\"] if g != pk]\n\n        all_node_ids = set()\n        for lesson in lesson_data:\n            all_node_ids |= set(lesson.get(\"node_ids\"))\n        for exam in exam_data:\n            exam_node_ids = [question['exercise_id'] for question in exam.get(\"question_sources\")]\n            all_node_ids |= set(exam_node_ids)\n\n        query_content = ContentNode.objects.filter(id__in=all_node_ids)\n\n        learners_data = data(UserSerializer, query_learners)\n\n        output = {\n            \"id\": pk,\n            \"name\": classroom.name,\n            \"coaches\": data(UserSerializer, classroom.get_coaches()),\n            \"learners\": learners_data,\n            \"groups\": data(GroupSerializer, classroom.get_learner_groups()),\n            \"exams\": exam_data,\n            \"exam_learner_status\": data(ExamStatusSerializer, query_exam_logs),\n            \"content\": data(ContentSerializer, query_content),\n            \"content_learner_status\": content_status_serializer(lesson_data, learners_data, classroom),\n            \"lessons\": lesson_data,\n        }\n\n        return Response(output)\n", "path": "kolibri/plugins/coach/class_summary_api.py"}], "after_files": [{"content": "from django.db.models import Max\nfrom django.db.models import Sum\nfrom django.shortcuts import get_object_or_404\nfrom rest_framework import serializers\nfrom rest_framework import viewsets\nfrom rest_framework.response import Response\n\nfrom kolibri.core.auth import models as auth_models\nfrom kolibri.core.content.models import ContentNode\nfrom kolibri.core.exams.models import Exam\nfrom kolibri.core.lessons.models import Lesson\nfrom kolibri.core.logger import models as logger_models\nfrom kolibri.core.notifications.models import LearnerProgressNotification\nfrom kolibri.core.notifications.models import NotificationEventType\n\n\nNOT_STARTED = \"not_started\"\nSTARTED = \"started\"\nHELP_NEEDED = \"help_needed\"\nCOMPLETED = \"completed\"\n\n\ndef content_status_serializer(lesson_data, learners_data, classroom):\n\n    # First generate a unique set of content node ids from all the lessons\n    lesson_node_ids = set()\n    for lesson in lesson_data:\n        lesson_node_ids |= set(lesson.get(\"node_ids\"))\n\n    # Now create a map of content_id to node_id so that we can map between lessons, and notifications\n    # which use the node id, and summary logs, which use content_id\n    content_map = {n[0]: n[1] for n in ContentNode.objects.filter(id__in=lesson_node_ids).values_list(\"content_id\", \"id\")}\n\n    # Get all the values we need from the summary logs to be able to summarize current status on the\n    # relevant content items.\n    content_log_values = logger_models.ContentSummaryLog.objects.filter(\n        content_id__in=set(content_map.keys()), user__in=[learner[\"id\"] for learner in learners_data]\n    ).values(\"user_id\", \"content_id\", \"end_timestamp\", \"time_spent\", \"progress\")\n\n    # In order to make the lookup speedy, generate a unique key for each user/node that we find\n    # listed in the needs help notifications that are relevant. We can then just check\n    # existence of this key in the set in order to see whether this user has been flagged as needing\n    # help.\n    lookup_key = \"{user_id}-{node_id}\"\n    needs_help = {\n        lookup_key.format(user_id=n[0], node_id=n[1]): n[2] for n in LearnerProgressNotification.objects.filter(\n            classroom_id=classroom.id,\n            notification_event=NotificationEventType.Help,\n            lesson_id__in=[lesson[\"id\"] for lesson in lesson_data],\n        ).values_list(\"user_id\", \"contentnode_id\", \"timestamp\")\n    }\n\n    # In case a previously flagged learner has since completed an exercise, check all the completed\n    # notifications also\n    completed = {\n        lookup_key.format(user_id=n[0], node_id=n[1]): n[2] for n in LearnerProgressNotification.objects.filter(\n            classroom_id=classroom.id,\n            notification_event=NotificationEventType.Completed,\n            lesson_id__in=[lesson[\"id\"] for lesson in lesson_data],\n        ).values_list(\"user_id\", \"contentnode_id\", \"timestamp\")\n    }\n\n    def get_status(log):\n        \"\"\"\n        Read the dict from a content summary log values query and return the status\n        In the case that we have found a needs help notification for the user and content node\n        in question, return that they need help, otherwise return status based on their\n        current progress.\n        \"\"\"\n        content_id = log[\"content_id\"]\n        if content_id in content_map:\n            # Don't try to lookup anything if we don't know the content_id\n            # node_id mapping - might happen if a channel has since been deleted\n            key = lookup_key.format(user_id=log[\"user_id\"], node_id=content_map[content_id])\n            if key in needs_help:\n                # Now check if we have not already registered completion of the content node\n                # or if we have and the timestamp is earlier than that on the needs_help event\n                if key not in completed or completed[key] < needs_help[key]:\n                    return HELP_NEEDED\n        if log[\"progress\"] == 1:\n            return COMPLETED\n        elif log[\"progress\"] == 0:\n            return NOT_STARTED\n        return STARTED\n\n    def map_content_logs(log):\n        \"\"\"\n        Parse the content logs to return objects in the expected format.\n        \"\"\"\n        return {\n            \"learner_id\": log[\"user_id\"],\n            \"content_id\": log[\"content_id\"],\n            \"status\": get_status(log),\n            \"last_activity\": log[\"end_timestamp\"],\n            \"time_spent\": log[\"time_spent\"],\n        }\n\n    return map(map_content_logs, content_log_values)\n\n\nclass ExamStatusSerializer(serializers.ModelSerializer):\n    status = serializers.SerializerMethodField()\n    exam_id = serializers.PrimaryKeyRelatedField(source=\"exam\", read_only=True)\n    learner_id = serializers.PrimaryKeyRelatedField(source=\"user\", read_only=True)\n    last_activity = serializers.CharField()\n    num_correct = serializers.SerializerMethodField()\n\n    def get_status(self, exam_log):\n        if exam_log.closed:\n            return COMPLETED\n        else:\n            return STARTED\n\n    def get_num_correct(self, exam_log):\n        return (\n            exam_log.attemptlogs.values_list('item')\n            .order_by('completion_timestamp')\n            .distinct()\n            .aggregate(Sum('correct'))\n            .get('correct__sum')\n        )\n\n    class Meta:\n        model = logger_models.ExamLog\n        fields = (\"exam_id\", \"learner_id\", \"status\", \"last_activity\", \"num_correct\")\n\n\nclass GroupSerializer(serializers.ModelSerializer):\n    member_ids = serializers.SerializerMethodField()\n\n    def get_member_ids(self, group):\n        return group.get_members().values_list(\"id\", flat=True)\n\n    class Meta:\n        model = auth_models.LearnerGroup\n        fields = (\"id\", \"name\", \"member_ids\")\n\n\nclass UserSerializer(serializers.ModelSerializer):\n    name = serializers.CharField(source=\"full_name\")\n\n    class Meta:\n        model = auth_models.FacilityUser\n        fields = (\"id\", \"name\", \"username\")\n\n\nclass LessonNodeIdsField(serializers.Field):\n    def to_representation(self, values):\n        return [value[\"contentnode_id\"] for value in values]\n\n\nclass LessonAssignmentsField(serializers.RelatedField):\n    def to_representation(self, assignment):\n        return assignment.collection.id\n\n\nclass LessonSerializer(serializers.ModelSerializer):\n    active = serializers.BooleanField(source=\"is_active\")\n    node_ids = LessonNodeIdsField(default=[], source=\"resources\")\n\n    # classrooms are in here, and filtered out later\n    groups = LessonAssignmentsField(\n        many=True, read_only=True, source=\"lesson_assignments\"\n    )\n\n    class Meta:\n        model = Lesson\n        fields = (\"id\", \"title\", \"active\", \"node_ids\", \"groups\")\n\n\nclass ExamQuestionSourcesField(serializers.Field):\n    def to_representation(self, values):\n        return values\n\n\nclass ExamAssignmentsField(serializers.RelatedField):\n    def to_representation(self, assignment):\n        return assignment.collection.id\n\n\nclass ExamSerializer(serializers.ModelSerializer):\n\n    question_sources = ExamQuestionSourcesField(default=[])\n\n    # classes are in here, and filtered out later\n    groups = ExamAssignmentsField(many=True, read_only=True, source=\"assignments\")\n\n    class Meta:\n        model = Exam\n        fields = (\"id\", \"title\", \"active\", \"question_sources\", \"groups\")\n\n\nclass ContentSerializer(serializers.ModelSerializer):\n    node_id = serializers.CharField(source=\"id\")\n\n    class Meta:\n        model = ContentNode\n        fields = (\"node_id\", \"content_id\", \"title\", \"kind\")\n\n\ndef data(Serializer, queryset):\n    return Serializer(queryset, many=True).data\n\n\nclass ClassSummaryViewSet(viewsets.ViewSet):\n    def retrieve(self, request, pk):\n        classroom = get_object_or_404(auth_models.Classroom, id=pk)\n        query_learners = classroom.get_members()\n        query_lesson = Lesson.objects.filter(collection=pk)\n        query_exams = Exam.objects.filter(collection=pk)\n        query_exam_logs = logger_models.ExamLog.objects.filter(\n            exam__in=query_exams\n        ).annotate(last_activity=Max(\"attemptlogs__end_timestamp\"))\n\n        lesson_data = data(LessonSerializer, query_lesson)\n        exam_data = data(ExamSerializer, query_exams)\n\n        # filter classes out of exam assignments\n        for exam in exam_data:\n            exam[\"groups\"] = [g for g in exam[\"groups\"] if g != pk]\n\n        # filter classes out of lesson assignments\n        for lesson in lesson_data:\n            lesson[\"groups\"] = [g for g in lesson[\"groups\"] if g != pk]\n\n        all_node_ids = set()\n        for lesson in lesson_data:\n            all_node_ids |= set(lesson.get(\"node_ids\"))\n        for exam in exam_data:\n            exam_node_ids = [question['exercise_id'] for question in exam.get(\"question_sources\")]\n            all_node_ids |= set(exam_node_ids)\n\n        query_content = ContentNode.objects.filter(id__in=all_node_ids)\n\n        learners_data = data(UserSerializer, query_learners)\n\n        output = {\n            \"id\": pk,\n            \"name\": classroom.name,\n            \"coaches\": data(UserSerializer, classroom.get_coaches()),\n            \"learners\": learners_data,\n            \"groups\": data(GroupSerializer, classroom.get_learner_groups()),\n            \"exams\": exam_data,\n            \"exam_learner_status\": data(ExamStatusSerializer, query_exam_logs),\n            \"content\": data(ContentSerializer, query_content),\n            \"content_learner_status\": content_status_serializer(lesson_data, learners_data, classroom),\n            \"lessons\": lesson_data,\n        }\n\n        return Response(output)\n", "path": "kolibri/plugins/coach/class_summary_api.py"}]}