Repository: learningequality/kolibri

Issue:
'help needed' is not currently being reported by the class_summary_api for content
### Observed behavior

Haven't implemented yet:

https://github.com/learningequality/kolibri/blob/develop/kolibri/plugins/coach/class_summary_api.py#L27-L34

### Expected behavior

This query runs fairly often, so we need to be careful about performance. Currently 'needs help' would require a join with the attempt logs, which would be run for every single content item in every lesson in the class.

I'm thinking it would be preferable to add a `help_needed` field to the summary log to make this cheaper. The basic idea would be to update the `help_needed` flag at the same time as notifications are generated.

### User-facing consequences

currently 'needs help' is not reported in our reports




### Context

0.12

----


cc @jredrejo @rtibbles I could use a little help with this if either of you have time




Discussion:
It was my understanding that needs help was being implemented as a notification? Not as an additional field on the summary log, so that it could be contextualized to a lesson if needed?
Will take this on!
@rtibbles 
> It was my understanding that needs help was being implemented as a notification? Not as an additional field on the summary log, so that it could be contextualized to a lesson if needed?

You are right: 
https://github.com/learningequality/kolibri/blob/develop/kolibri/core/notifications/api.py#L273-L291

do you want me to add this to the summarylog or are you doing it

Resolving patch:
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
@@ -263,7 +263,7 @@ def parse_attemptslog(attemptlog):
     lessons = get_assignments(attemptlog, attemptlog.masterylog.summarylog, attempt=True)
     if not lessons:
         return
-    # get all the atempts log on this exercise:
+    # get all the attempt logs on this exercise:
     failed_interactions = []
     attempts = AttemptLog.objects.filter(masterylog_id=attemptlog.masterylog_id)
     failed_interactions = [failed for attempt in attempts for failed in attempt.interaction_history if failed['correct'] == 0]
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
@@ -1,16 +1,17 @@
+from django.db.models import Max
 from django.db.models import Sum
-from rest_framework import viewsets
+from django.shortcuts import get_object_or_404
 from rest_framework import serializers
+from rest_framework import viewsets
 from rest_framework.response import Response
 
 from kolibri.core.auth import models as auth_models
-from kolibri.core.logger import models as logger_models
 from kolibri.core.content.models import ContentNode
-from kolibri.core.lessons.models import Lesson
 from kolibri.core.exams.models import Exam
-
-
-from django.db.models import Max
+from kolibri.core.lessons.models import Lesson
+from kolibri.core.logger import models as logger_models
+from kolibri.core.notifications.models import LearnerProgressNotification
+from kolibri.core.notifications.models import NotificationEventType
 
 
 NOT_STARTED = "not_started"
@@ -19,23 +20,82 @@
 COMPLETED = "completed"
 
 
-class ContentStatusSerializer(serializers.ModelSerializer):
-    status = serializers.SerializerMethodField()
-    learner_id = serializers.PrimaryKeyRelatedField(source="user", read_only=True)
-    last_activity = serializers.CharField(source="end_timestamp")
-
-    def get_status(self, content_summary_log):
-        # if content_summary_log.help_needed:
-        #     return HELP_NEEDED
-        if content_summary_log.progress == 1:
+def content_status_serializer(lesson_data, learners_data, classroom):
+
+    # First generate a unique set of content node ids from all the lessons
+    lesson_node_ids = set()
+    for lesson in lesson_data:
+        lesson_node_ids |= set(lesson.get("node_ids"))
+
+    # Now create a map of content_id to node_id so that we can map between lessons, and notifications
+    # which use the node id, and summary logs, which use content_id
+    content_map = {n[0]: n[1] for n in ContentNode.objects.filter(id__in=lesson_node_ids).values_list("content_id", "id")}
+
+    # Get all the values we need from the summary logs to be able to summarize current status on the
+    # relevant content items.
+    content_log_values = logger_models.ContentSummaryLog.objects.filter(
+        content_id__in=set(content_map.keys()), user__in=[learner["id"] for learner in learners_data]
+    ).values("user_id", "content_id", "end_timestamp", "time_spent", "progress")
+
+    # In order to make the lookup speedy, generate a unique key for each user/node that we find
+    # listed in the needs help notifications that are relevant. We can then just check
+    # existence of this key in the set in order to see whether this user has been flagged as needing
+    # help.
+    lookup_key = "{user_id}-{node_id}"
+    needs_help = {
+        lookup_key.format(user_id=n[0], node_id=n[1]): n[2] for n in LearnerProgressNotification.objects.filter(
+            classroom_id=classroom.id,
+            notification_event=NotificationEventType.Help,
+            lesson_id__in=[lesson["id"] for lesson in lesson_data],
+        ).values_list("user_id", "contentnode_id", "timestamp")
+    }
+
+    # In case a previously flagged learner has since completed an exercise, check all the completed
+    # notifications also
+    completed = {
+        lookup_key.format(user_id=n[0], node_id=n[1]): n[2] for n in LearnerProgressNotification.objects.filter(
+            classroom_id=classroom.id,
+            notification_event=NotificationEventType.Completed,
+            lesson_id__in=[lesson["id"] for lesson in lesson_data],
+        ).values_list("user_id", "contentnode_id", "timestamp")
+    }
+
+    def get_status(log):
+        """
+        Read the dict from a content summary log values query and return the status
+        In the case that we have found a needs help notification for the user and content node
+        in question, return that they need help, otherwise return status based on their
+        current progress.
+        """
+        content_id = log["content_id"]
+        if content_id in content_map:
+            # Don't try to lookup anything if we don't know the content_id
+            # node_id mapping - might happen if a channel has since been deleted
+            key = lookup_key.format(user_id=log["user_id"], node_id=content_map[content_id])
+            if key in needs_help:
+                # Now check if we have not already registered completion of the content node
+                # or if we have and the timestamp is earlier than that on the needs_help event
+                if key not in completed or completed[key] < needs_help[key]:
+                    return HELP_NEEDED
+        if log["progress"] == 1:
             return COMPLETED
-        elif content_summary_log.progress == 0:
+        elif log["progress"] == 0:
             return NOT_STARTED
         return STARTED
 
-    class Meta:
-        model = logger_models.ContentSummaryLog
-        fields = ("learner_id", "content_id", "status", "last_activity", "time_spent")
+    def map_content_logs(log):
+        """
+        Parse the content logs to return objects in the expected format.
+        """
+        return {
+            "learner_id": log["user_id"],
+            "content_id": log["content_id"],
+            "status": get_status(log),
+            "last_activity": log["end_timestamp"],
+            "time_spent": log["time_spent"],
+        }
+
+    return map(map_content_logs, content_log_values)
 
 
 class ExamStatusSerializer(serializers.ModelSerializer):
@@ -145,7 +205,7 @@ def data(Serializer, queryset):
 
 class ClassSummaryViewSet(viewsets.ViewSet):
     def retrieve(self, request, pk):
-        classroom = auth_models.Classroom.objects.filter(id=pk).get()
+        classroom = get_object_or_404(auth_models.Classroom, id=pk)
         query_learners = classroom.get_members()
         query_lesson = Lesson.objects.filter(collection=pk)
         query_exams = Exam.objects.filter(collection=pk)
@@ -173,21 +233,18 @@ def retrieve(self, request, pk):
 
         query_content = ContentNode.objects.filter(id__in=all_node_ids)
 
-        learner_ids = list(query_learners.values_list("id", flat=True))
-        query_content_logs = logger_models.ContentSummaryLog.objects.filter(
-            content_id__in=query_content.values_list("content_id"), user__in=learner_ids
-        )
+        learners_data = data(UserSerializer, query_learners)
 
         output = {
             "id": pk,
             "name": classroom.name,
             "coaches": data(UserSerializer, classroom.get_coaches()),
-            "learners": data(UserSerializer, query_learners),
+            "learners": learners_data,
             "groups": data(GroupSerializer, classroom.get_learner_groups()),
             "exams": exam_data,
             "exam_learner_status": data(ExamStatusSerializer, query_exam_logs),
             "content": data(ContentSerializer, query_content),
-            "content_learner_status": data(ContentStatusSerializer, query_content_logs),
+            "content_learner_status": content_status_serializer(lesson_data, learners_data, classroom),
             "lessons": lesson_data,
         }
 


Test patch: