Repository: learningequality/kolibri

Issue:
👻 Unavailable resources on Coach Page are properly displayed
### Observed behavior

Some work related to missing content didn't get implemented in 0.14, but the strings for the functionality were added and have been translated.

Refs:

* https://www.figma.com/file/LM0YUkbWAOIerZ3EEg0FN6bS/0.14-P2P-Sync?node-id=547%3A11
* https://www.notion.so/learningequality/Data-syncing-and-facility-management-0-14-0-44a8d0e4a5674d35b2a18a440e967cea

![image](https://user-images.githubusercontent.com/2367265/87807608-4ff7ee80-c80d-11ea-99d0-569d0cac25d5.png)


### User-facing consequences

Users of data syncing could end up with very confusing user experiences




### Context

0.14.0



Discussion:
Automatically importing associated content when syncing facility would be much cleaner than showing error messages, this will also allow teachers to create lessons with content from multiple channels without students having to sync all of those channels.
@cyberorg yes, we completely agree and we are definitely looking to allow content to be synced with data.

That said, there will always still be situations where we need to handle missing content so this update is required regardless.

In 0.15.1b0, I observed the following in the coach notifications when the respective channel (and hence metadata) wasn't loaded onto the device:
![image](https://user-images.githubusercontent.com/618416/153102226-5ced82a0-6403-4d13-a671-0108b9b264b4.png)

(this usage data was generated on another device, and synced in here)

From @jtamiace:
> "started a resource in this lesson" sounds like a good way to handle that, along with flags/explanations like here https://www.figma.com/file/LM0YUkbWAOIerZ3EEg0FN6bS/0.14-P2P-Sync?node-id=1034%3A11
Moving this to 0.16, as it seems like the design fix suggested will require new strings, and I do not see the referenced string

> "started a resource in this lesson" 

in Kolibri

Resolving patch:
diff --git a/kolibri/core/content/api.py b/kolibri/core/content/api.py
--- a/kolibri/core/content/api.py
+++ b/kolibri/core/content/api.py
@@ -553,6 +553,8 @@ class BaseContentNodeMixin(object):
     }
 
     def get_queryset(self):
+        if self.request.GET.get("no_available_filtering", False):
+            return models.ContentNode.objects.all()
         return models.ContentNode.objects.filter(available=True)
 
     def get_related_data_maps(self, items, queryset):
@@ -1319,7 +1321,10 @@ class UserContentNodeFilter(ContentNodeFilter):
 
     def filter_by_lesson(self, queryset, name, value):
         try:
-            lesson = Lesson.objects.get(pk=value)
+            lesson = Lesson.objects.filter(
+                lesson_assignments__collection__membership__user=self.request.user,
+                is_active=True,
+            ).get(pk=value)
             node_ids = list(map(lambda x: x["contentnode_id"], lesson.resources))
             return queryset.filter(pk__in=node_ids)
         except Lesson.DoesNotExist:
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
@@ -274,6 +274,9 @@ def serialize_lessons(queryset):
 
 def _map_exam(item):
     item["assignments"] = item.pop("exam_assignments")
+    item["node_ids"] = list(
+        {question["exercise_id"] for question in item.get("question_sources")}
+    )
     return item
 
 
@@ -331,6 +334,26 @@ def retrieve(self, request, pk):
         lesson_data = serialize_lessons(query_lesson)
         exam_data = serialize_exams(query_exams)
 
+        all_node_ids = set()
+        for lesson in lesson_data:
+            all_node_ids |= set(lesson.get("node_ids"))
+        for exam in exam_data:
+            all_node_ids |= set(exam.get("node_ids"))
+
+        content = list(
+            ContentNode.objects.filter_by_uuids(all_node_ids).values(
+                "available",
+                "content_id",
+                "title",
+                "kind",
+                "channel_id",
+                "options",
+                node_id=F("id"),
+            )
+        )
+        # final list of available nodes
+        node_lookup = {node["node_id"]: node for node in content}
+
         individual_learners_group_ids = AdHocGroup.objects.filter(
             parent=classroom
         ).values_list("id", flat=True)
@@ -342,6 +365,11 @@ def retrieve(self, request, pk):
                 for g in exam["assignments"]
                 if g != pk and g not in individual_learners_group_ids
             ]
+            # determine if any resources are missing locally for the quiz
+            exam["missing_resource"] = any(
+                node_id not in node_lookup or not node_lookup[node_id]["available"]
+                for node_id in exam["node_ids"]
+            )
 
         # filter classes out of lesson assignments
         for lesson in lesson_data:
@@ -350,41 +378,11 @@ def retrieve(self, request, pk):
                 for g in lesson["assignments"]
                 if g != pk and g not in individual_learners_group_ids
             ]
-
-        all_node_ids = set()
-        for lesson in lesson_data:
-            all_node_ids |= set(lesson.get("node_ids"))
-        for exam in exam_data:
-            exam_node_ids = [
-                question["exercise_id"] for question in exam.get("question_sources")
-            ]
-            all_node_ids |= set(exam_node_ids)
-
-        # map node ids => content_ids so we can replace missing nodes, if another matching content_id node exists
-        content_id_map = {
-            resource["contentnode_id"]: resource["content_id"]
-            for lesson in lesson_data
-            for resource in (lesson.pop("resources") or [])
-        }
-        query_content = ContentNode.objects.filter_by_uuids(all_node_ids)
-        # final list of available nodes
-        list_of_ids = [node.id for node in query_content]
-        # determine a new list of node_ids for each lesson, removing/replacing missing content items
-        for lesson in lesson_data:
-            node_ids = []
-            for node_id in lesson["node_ids"]:
-                # if resource exists, add to node_ids
-                if node_id in list_of_ids:
-                    node_ids.append(node_id)
-                else:
-                    # if resource does not exist, check if another resource with same content_id exists
-                    nodes = ContentNode.objects.filter(
-                        content_id=content_id_map[node_id]
-                    )
-                    if nodes:
-                        node_ids.append(nodes[0].id)
-            # point to new list of node ids
-            lesson["node_ids"] = node_ids
+            # determine if any resources are missing locally for the lesson
+            lesson["missing_resource"] = any(
+                node_id not in node_lookup or not node_lookup[node_id]["available"]
+                for node_id in lesson["node_ids"]
+            )
 
         learners_data = serialize_users(query_learners)
 
@@ -404,16 +402,7 @@ def retrieve(self, request, pk):
             ),
             "exams": exam_data,
             "exam_learner_status": serialize_coach_assigned_quiz_status(query_exams),
-            "content": list(
-                query_content.values(
-                    "content_id",
-                    "title",
-                    "kind",
-                    "channel_id",
-                    "options",
-                    node_id=F("id"),
-                )
-            ),
+            "content": content,
             "content_learner_status": content_status_serializer(
                 lesson_data, learners_data, classroom
             ),
diff --git a/kolibri/plugins/learn/viewsets.py b/kolibri/plugins/learn/viewsets.py
--- a/kolibri/plugins/learn/viewsets.py
+++ b/kolibri/plugins/learn/viewsets.py
@@ -14,6 +14,7 @@
 from kolibri.core.content.api import ContentNodeProgressViewset
 from kolibri.core.content.api import ContentNodeViewset
 from kolibri.core.content.api import UserContentNodeViewset
+from kolibri.core.content.models import ContentNode
 from kolibri.core.exams.models import Exam
 from kolibri.core.lessons.models import Lesson
 from kolibri.core.logger.models import AttemptLog
@@ -84,11 +85,14 @@ def _consolidate_lessons_data(request, lessons):
             ),
             "total_resources": len(lesson["resources"]),
         }
+        missing_resource = False
         for resource in lesson["resources"]:
             resource["progress"] = progress_map.get(resource["content_id"], 0)
             resource["contentnode"] = contentnode_map.get(
                 resource["contentnode_id"], None
             )
+            missing_resource = missing_resource or not resource["contentnode"]
+        lesson["missing_resource"] = missing_resource
 
 
 class LearnerClassroomViewset(ReadOnlyValuesViewset):
@@ -174,6 +178,18 @@ def consolidate(self, items, queryset):
                 "closed",
                 "answer_count",
                 "score",
+                "question_sources",
+            )
+        )
+        exam_node_ids = set()
+        for exam in exams:
+            exam_node_ids |= {
+                question["exercise_id"] for question in exam.get("question_sources")
+            }
+
+        available_exam_ids = set(
+            ContentNode.objects.filter_by_uuids(exam_node_ids).values_list(
+                "id", flat=True
             )
         )
 
@@ -195,6 +211,10 @@ def consolidate(self, items, queryset):
                     "closed": None,
                     "started": False,
                 }
+            exam["missing_resource"] = any(
+                question["exercise_id"] not in available_exam_ids
+                for question in exam.get("question_sources")
+            )
         out_items = []
         for item in items:
             item["assignments"] = {


Test patch:
diff --git a/kolibri/plugins/coach/test/test_class_summary.py b/kolibri/plugins/coach/test/test_class_summary.py
--- a/kolibri/plugins/coach/test/test_class_summary.py
+++ b/kolibri/plugins/coach/test/test_class_summary.py
@@ -81,7 +81,7 @@ def setUpTestData(cls):
         cls.basename = "kolibri:kolibri.plugins.coach:classsummary"
         cls.detail_name = cls.basename + "-detail"
 
-    def test_non_existent_nodes_dont_show_up_in_lessons(self):
+    def test_non_existent_nodes_do_show_up_in_lessons(self):
         node = ContentNode.objects.exclude(kind=content_kinds.TOPIC).first()
         last_node = ContentNode.objects.exclude(kind=content_kinds.TOPIC).last()
         real_data = {
@@ -108,11 +108,13 @@ def test_non_existent_nodes_dont_show_up_in_lessons(self):
         response = self.client.get(
             reverse(self.detail_name, kwargs={"pk": self.classroom.id})
         )
-        node_ids = response.data["lessons"][0]["node_ids"]
+        lesson = response.data["lessons"][0]
+        node_ids = lesson["node_ids"]
         self.assertIn(real_data["contentnode_id"], node_ids)
         # swapped data
-        self.assertIn(last_node.id, node_ids)
-        self.assertNotIn(fake_data["contentnode_id"], node_ids)
+        self.assertNotIn(last_node.id, node_ids)
+        self.assertIn(fake_data["contentnode_id"], node_ids)
+        self.assertTrue(lesson["missing_resource"])
 
     def test_anon_user_cannot_access_detail(self):
         response = self.client.get(