Repository: learningequality/kolibri

Issue:
Learner Lesson Playlist does not update automatically
### Observed behavior

1. I try to edit a Lesson X(e.g. add a resource) as a Coach
1. Go to the Learn page (in coach account), then Lesson Playlist (for Lesson X) page, but the Playlist is not up-to-date. The Lesson API is returning the correct object, but the `contentnode_slim?in_lesson` response is not returning all of the resources.
1. Refreshing does not update the list
1. However, if I log-out, then log back in, then go back to the Lesson Playlist, the resources are up to date.


### Expected behavior

The LessonPlaylist Page is up-to-date and reflects the latest changes to a lesson

### User-facing consequences
<!--
Implications and real-world consequences for learners, coaches, admins, and other users of the application
-->

…

### Errors and logs
<!--
Relevant logs from:
 * the command line
 * ~/.kolibri/kolibri.log
 * the browser console

Please wrap errors in triple backticks for clean formatting like this:
```
01:10 info: something happened
01:12 error: something bad happened
```
-->

…

### Steps to reproduce
<!--
Precise steps that someone else can follow in order to see this behavior
-->

…

### Context
<!--
Tell us about your environment, including:
 * Kolibri version
 * Operating system
 * Browser
-->

…



Discussion:
Adding a `force: true` on this line doesn't seem to work. https://github.com/jonboiser/kolibri/blob/2bc7be47415654837858c20bf1e5c7d1700b20fa/kolibri/plugins/learn/assets/src/modules/lessonPlaylist/handlers.js#L16
cc @rtibbles perhaps the result of more aggressive recent caching?
Yes, seems right as it is on the content node endpoint.
Have updated milestone as will effect 0.10 too, as I believe they both use the "in lesson" get param. 
Chatted with @ralphiee22 and as the `in_lesson` get param is just a covenience wrapper around the previous `ids` behaviour that was used here, reverting this seems like the simplest fix.
`in_lesson` is slightly safer than `ids` because it works even if the underlying channel has been deleted. In the coach lesson's workflow, we still use the `id` filter and the Lesson Summary Page can crash if any resource's channel has been deleted.

Resolving patch:
diff --git a/kolibri/content/api.py b/kolibri/content/api.py
--- a/kolibri/content/api.py
+++ b/kolibri/content/api.py
@@ -5,10 +5,8 @@
 
 import requests
 from django.core.cache import cache
-from django.db.models import Case
 from django.db.models import Q
 from django.db.models import Sum
-from django.db.models import When
 from django.db.models.aggregates import Count
 from django.http import Http404
 from django.utils.cache import patch_response_headers
@@ -35,8 +33,6 @@
 from kolibri.content.utils.content_types_tools import renderable_contentnodes_q_filter
 from kolibri.content.utils.paths import get_channel_lookup_url
 from kolibri.content.utils.stopwords import stopwords_set
-from kolibri.core.exams.models import Exam
-from kolibri.core.lessons.models import Lesson
 from kolibri.logger.models import ContentSessionLog
 from kolibri.logger.models import ContentSummaryLog
 
@@ -99,7 +95,11 @@ class IdFilter(FilterSet):
     ids = CharFilter(method="filter_ids")
 
     def filter_ids(self, queryset, name, value):
-        return queryset.filter(pk__in=value.split(','))
+        try:
+            return queryset.filter(pk__in=value.split(','))
+        except ValueError:
+            # Catch in case of a poorly formed UUID
+            return queryset.none()
 
     class Meta:
         fields = ['ids', ]
@@ -329,38 +329,6 @@ def filter_by_role(self, queryset, name, value):
         # In all other cases, exclude nodes that are coach content
         return queryset.exclude(coach_content=True)
 
-    def filter_in_lesson(self, queryset, name, value):
-        """
-        Show only content associated with this lesson
-
-        :param queryset: all content nodes
-        :param value: id of target lesson
-        :return: content nodes for this lesson
-        """
-        try:
-            resources = Lesson.objects.get(id=value).resources
-            contentnode_id_list = [node['contentnode_id'] for node in resources]
-            # the order_by will order according to the position in the list
-            id_order = Case(*[When(id=ident, then=pos) for pos, ident in enumerate(contentnode_id_list)])
-            return queryset.filter(pk__in=contentnode_id_list).order_by(id_order)
-        except (Lesson.DoesNotExist, ValueError):  # also handles invalid uuid
-            queryset.none()
-
-    def filter_in_exam(self, queryset, name, value):
-        """
-        Show only content associated with this exam
-
-        :param queryset: all content nodes
-        :param value: id of target exam
-        :return: content nodes for this exam
-        """
-        try:
-            question_sources = Exam.objects.get(id=value).question_sources
-            exercise_id_list = [node['exercise_id'] for node in question_sources]
-            return queryset.filter(pk__in=exercise_id_list)
-        except (Exam.DoesNotExist, ValueError):  # also handles invalid uuid
-            return queryset.none()
-
 
 class OptionalPageNumberPagination(pagination.PageNumberPagination):
     """


Test patch:
diff --git a/kolibri/content/test/test_content_app.py b/kolibri/content/test/test_content_app.py
--- a/kolibri/content/test/test_content_app.py
+++ b/kolibri/content/test/test_content_app.py
@@ -22,8 +22,6 @@
 from kolibri.content import models as content
 from kolibri.core.device.models import DevicePermissions
 from kolibri.core.device.models import DeviceSettings
-from kolibri.core.exams.models import Exam
-from kolibri.core.lessons.models import Lesson
 from kolibri.logger.models import ContentSessionLog
 from kolibri.logger.models import ContentSummaryLog
 
@@ -626,80 +624,6 @@ def test_filtering_coach_content_admin(self):
         expected_output = content.ContentNode.objects.exclude(available=False).count()  # coach_content node should be returned
         self.assertEqual(len(response.data), expected_output)
 
-    def _setup_lesson(self):
-        facility = Facility.objects.create(name="MyFac")
-        admin = FacilityUser.objects.create(username="admin", facility=facility)
-        admin.set_password(DUMMY_PASSWORD)
-        admin.save()
-        nodes = []
-        nodes.append(content.ContentNode.objects.get(title='c3c1'))
-        nodes.append(content.ContentNode.objects.get(title='c2c3'))
-        nodes.append(content.ContentNode.objects.get(title='c2c2'))
-        json_resource = [{"contentnode_id": node.id, "content_id": node.content_id, "channel_id": node.channel_id} for node in nodes]
-        lesson = Lesson.objects.create(
-            title="title",
-            is_active=True,
-            collection=facility,
-            created_by=admin,
-            resources=json_resource
-        )
-        return lesson, nodes
-
-    def test_in_lesson_filter(self):
-        lesson, nodes = self._setup_lesson()
-        response = self.client.get(self._reverse_channel_url("contentnode-list"), data={"in_lesson": lesson.id})
-        self.assertEqual(len(response.data), len(lesson.resources))
-        for counter, node in enumerate(nodes):
-            self.assertEqual(response.data[counter]['id'], node.id)
-
-    def test_in_lesson_filter_invalid_value(self):
-        self._setup_lesson()
-
-        # request with invalid uuid
-        response = self.client.get(self._reverse_channel_url("contentnode-list"), data={"in_lesson": '123'})
-        self.assertEqual(len(response.data), 0)
-
-        # request with valid uuid
-        response = self.client.get(self._reverse_channel_url("contentnode-list"), data={"in_lesson": '47385a6d4df3426db38ad0d20e113dce'})
-        self.assertEqual(len(response.data), 0)
-
-    def _setup_exam(self):
-        facility = Facility.objects.create(name="MyFac")
-        admin = FacilityUser.objects.create(username="admin", facility=facility)
-        admin.set_password(DUMMY_PASSWORD)
-        admin.save()
-        node = content.ContentNode.objects.get(title='c3c1')
-        exam = Exam.objects.create(
-            title="title",
-            channel_id="test",
-            question_count=1,
-            active=True,
-            collection=facility,
-            creator=admin,
-            question_sources=[
-                {"exercise_id": node.id, "number_of_questions": 6}
-            ]
-        )
-
-        return exam, node
-
-    def test_in_exam_filter(self):
-        exam, node = self._setup_exam()
-        response = self.client.get(self._reverse_channel_url("contentnode-list"), data={"in_exam": exam.id})
-        self.assertEqual(len(response.data), len(exam.question_sources))
-        self.assertEqual(response.data[0]['id'], node.id)
-
-    def test_in_exam_filter_invalid_value(self):
-        self._setup_exam()
-
-        # request with invalid uuid
-        response = self.client.get(self._reverse_channel_url("contentnode-list"), data={"in_exam": '123'})
-        self.assertEqual(len(response.data), 0)
-
-        # request with valid uuid
-        response = self.client.get(self._reverse_channel_url("contentnode-list"), data={"in_exam": '47385a6d4df3426db38ad0d20e113dce'})
-        self.assertEqual(len(response.data), 0)
-
     def test_copies(self):
         # the pk is actually a content id
         response = self.client.get(reverse('contentnode-copies', kwargs={'pk': 'c6f49ea527824f398f4d5d26faf19396'}))