Repository: learningequality/kolibri

Issue:
Remote Content Browsing: Update Channel and Library UI to use new shortened file path criteria
Implementing the skip logic described here:
![Frame 873](https://user-images.githubusercontent.com/1680573/207744700-0c17cc43-64a9-4157-b1e3-0c6de37cc0bb.png)

In addition, if the top level topic has only a single child, then it should instead display the first descendant with more than one folder child or a child that is a resource.


Discussion:

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
@@ -940,27 +940,50 @@ def validate_and_return_params(self, request):
 
         return depth, next__gt
 
+    def _get_gc_by_parent(self, child_ids):
+        # Use this to keep track of how many grand children we have accumulated per child of the parent node
+        gc_by_parent = {}
+        # Iterate through the grand children of the parent node in lft order so we follow the tree traversal order
+        for gc in (
+            self.filter_queryset(self.get_queryset())
+            .filter(parent_id__in=child_ids)
+            .values("id", "parent_id")
+            .order_by("lft")
+        ):
+            # If we have not already added a list of nodes to the gc_by_parent map, initialize it here
+            if gc["parent_id"] not in gc_by_parent:
+                gc_by_parent[gc["parent_id"]] = []
+            gc_by_parent[gc["parent_id"]].append(gc["id"])
+        return gc_by_parent
+
     def get_grandchild_ids(self, child_ids, depth, page_size):
+        grandchild_ids = []
         if depth == 2:
             # Use this to keep track of how many grand children we have accumulated per child of the parent node
-            gc_by_parent = {}
-            # Iterate through the grand children of the parent node in lft order so we follow the tree traversal order
-            for gc in (
-                self.filter_queryset(self.get_queryset())
-                .filter(parent_id__in=child_ids)
-                .values("id", "parent_id")
-                .order_by("lft")
-            ):
-                # If we have not already added a list of nodes to the gc_by_parent map, initialize it here
-                if gc["parent_id"] not in gc_by_parent:
-                    gc_by_parent[gc["parent_id"]] = []
-                # If the number of grand children for a specific child node is less than the page size
-                # then we keep on adding them to both lists
-                # If not, we just skip this node, as we have already hit the page limit for the node that is
-                # its parent.
-                if len(gc_by_parent[gc["parent_id"]]) < page_size:
-                    gc_by_parent[gc["parent_id"]].append(gc["id"])
-                    yield gc["id"]
+            gc_by_parent = self._get_gc_by_parent(child_ids)
+            singletons = []
+            # Now loop through each of the child_ids we passed in
+            # that have any children, check if any of them have only one
+            # child, and also add up to the page size to the list of
+            # grandchild_ids.
+            for child_id in gc_by_parent:
+                gc_ids = gc_by_parent[child_id]
+                if len(gc_ids) == 1:
+                    singletons.append(gc_ids[0])
+                # Only add up to the page size to the list
+                grandchild_ids.extend(gc_ids[:page_size])
+            if singletons:
+                grandchild_ids.extend(
+                    self.get_grandchild_ids(singletons, depth, page_size)
+                )
+        return grandchild_ids
+
+    def get_child_ids(self, parent_id, next__gt):
+        # Get a list of child_ids of the parent node up to the pagination limit
+        child_qs = self.get_queryset().filter(parent_id=parent_id)
+        if next__gt is not None:
+            child_qs = child_qs.filter(lft__gt=next__gt)
+        return child_qs.values_list("id", flat=True).order_by("lft")[0:NUM_CHILDREN]
 
     def get_tree_queryset(self, request, pk):
         # Get the model for the parent node here - we do this so that we trigger a 404 immediately if the node
@@ -975,18 +998,21 @@ def get_tree_queryset(self, request, pk):
             raise Http404
         depth, next__gt = self.validate_and_return_params(request)
 
-        # Get a list of child_ids of the parent node up to the pagination limit
-        child_qs = self.get_queryset().filter(parent_id=parent_id)
-        if next__gt is not None:
-            child_qs = child_qs.filter(lft__gt=next__gt)
-        child_ids = child_qs.values_list("id", flat=True).order_by("lft")[
-            0:NUM_CHILDREN
-        ]
+        child_ids = self.get_child_ids(parent_id, next__gt)
+
+        ancestor_ids = []
+
+        while next__gt is None and len(child_ids) == 1:
+            ancestor_ids.extend(child_ids)
+            child_ids = self.get_child_ids(child_ids[0], next__gt)
 
         # Get a flat list of ids for grandchildren we will be returning
         gc_ids = self.get_grandchild_ids(child_ids, depth, NUM_GRANDCHILDREN_PER_CHILD)
         return self.filter_queryset(self.get_queryset()).filter(
-            Q(id=parent_id) | Q(id__in=child_ids) | Q(id__in=gc_ids)
+            Q(id=parent_id)
+            | Q(id__in=ancestor_ids)
+            | Q(id__in=child_ids)
+            | Q(id__in=gc_ids)
         )
 
 
@@ -1026,19 +1052,19 @@ def retrieve(self, request, pk=None):
         # The serialized parent representation is the first node in the lft order
         parent = nodes[0]
 
-        # Use this to keep track of direct children of the parent node
-        # this will allow us to do lookups for the grandchildren, in order
+        # Use this to keep track of descendants of the parent node
+        # this will allow us to do lookups for any further descendants, in order
         # to insert them into the "children" property
-        children_by_id = {}
+        descendants_by_id = {}
 
         # Iterate through all the descendants that we have serialized
         for desc in nodes[1:]:
+            # Add them to the descendants_by_id map so that
+            # descendants can reference them later
+            descendants_by_id[desc["id"]] = desc
             # First check to see whether it is a direct child of the
             # parent node that we initially queried
             if desc["parent"] == pk:
-                # If so add them to the children_by_id map so that
-                # grandchildren descendants can reference them later
-                children_by_id[desc["id"]] = desc
                 # The parent of this descendant is the parent node
                 # for this query
                 desc_parent = parent
@@ -1048,11 +1074,11 @@ def retrieve(self, request, pk=None):
                 # For the parent node the page size is the maximum number of children
                 # we are returning (regardless of whether they have a full representation)
                 page_size = NUM_CHILDREN
-            elif desc["parent"] in children_by_id:
+            elif desc["parent"] in descendants_by_id:
                 # Otherwise, check to see if our descendant's parent is in the
-                # children_by_id map - if it failed the first condition,
+                # descendants_by_id map - if it failed the first condition,
                 # it really should not fail this
-                desc_parent = children_by_id[desc["parent"]]
+                desc_parent = descendants_by_id[desc["parent"]]
                 # When we request more results for pagination, we only want to return
                 # nodes at this level, and not any of its children
                 more_depth = 1


Test patch:
diff --git a/kolibri/core/content/test/test_content_app.py b/kolibri/core/content/test/test_content_app.py
--- a/kolibri/core/content/test/test_content_app.py
+++ b/kolibri/core/content/test/test_content_app.py
@@ -377,6 +377,7 @@ def test_contentnode_list_long(self):
         self._assert_nodes(response.data, nodes)
 
     def _recurse_and_assert(self, data, nodes, recursion_depth=0):
+        recursion_depths = []
         for actual, expected in zip(data, nodes):
             children = actual.pop("children", None)
             self._assert_node(actual, expected)
@@ -399,11 +400,14 @@ def _recurse_and_assert(self, data, nodes, recursion_depth=0):
                         self.assertEqual(
                             children["more"]["params"]["baseurl"], self.baseurl
                         )
-                self._recurse_and_assert(
-                    children["results"],
-                    child_nodes,
-                    recursion_depth=recursion_depth + 1,
+                recursion_depths.append(
+                    self._recurse_and_assert(
+                        children["results"],
+                        child_nodes,
+                        recursion_depth=recursion_depth + 1,
+                    )
                 )
+        return recursion_depth if not recursion_depths else max(recursion_depths)
 
     def test_contentnode_tree(self):
         root = content.ContentNode.objects.get(title="root")
@@ -490,6 +494,49 @@ def test_contentnode_tree_more(self):
         self.assertEqual(len(nested_page_response.data["children"]["results"]), 5)
         self.assertIsNone(nested_page_response.data["children"]["more"])
 
+    def test_contentnode_tree_singleton_path(self):
+        builder = ChannelBuilder(levels=5, num_children=1)
+        builder.insert_into_default_db()
+        content.ContentNode.objects.all().update(available=True)
+        root = content.ContentNode.objects.get(id=builder.root_node["id"])
+        response = self._get(
+            reverse("kolibri:core:contentnode_tree-detail", kwargs={"pk": root.id})
+        )
+        max_depth = self._recurse_and_assert([response.data], [root])
+        # Should recurse all the way down the tree through a total of 6 levels
+        # including the root.
+        self.assertEqual(max_depth, 6)
+
+    def test_contentnode_tree_singleton_child(self):
+        builder = ChannelBuilder(levels=5, num_children=2)
+        builder.insert_into_default_db()
+        content.ContentNode.objects.all().update(available=True)
+        root = content.ContentNode.objects.get(id=builder.root_node["id"])
+        first_child = root.children.first()
+        first_child.available = False
+        first_child.save()
+        response = self._get(
+            reverse("kolibri:core:contentnode_tree-detail", kwargs={"pk": root.id})
+        )
+        max_depth = self._recurse_and_assert([response.data], [root])
+        # Should recurse an extra level to find multiple descendants under the first grandchild.
+        self.assertEqual(max_depth, 3)
+
+    def test_contentnode_tree_singleton_grandchild(self):
+        builder = ChannelBuilder(levels=5, num_children=2)
+        builder.insert_into_default_db()
+        content.ContentNode.objects.all().update(available=True)
+        root = content.ContentNode.objects.get(id=builder.root_node["id"])
+        first_grandchild = root.children.first().children.first()
+        first_grandchild.available = False
+        first_grandchild.save()
+        response = self._get(
+            reverse("kolibri:core:contentnode_tree-detail", kwargs={"pk": root.id})
+        )
+        max_depth = self._recurse_and_assert([response.data], [root])
+        # Should recurse an extra level to find multiple descendants under the first child.
+        self.assertEqual(max_depth, 3)
+
 
 class ContentNodeAPITestCase(ContentNodeAPIBase, APITestCase):
     """
diff --git a/kolibri/core/tasks/test/taskrunner/test_worker.py b/kolibri/core/tasks/test/taskrunner/test_worker.py
--- a/kolibri/core/tasks/test/taskrunner/test_worker.py
+++ b/kolibri/core/tasks/test/taskrunner/test_worker.py
@@ -9,6 +9,7 @@
 from kolibri.core.tasks.test.base import connection
 from kolibri.core.tasks.test.taskrunner.test_job_running import EventProxy
 from kolibri.core.tasks.worker import Worker
+from kolibri.utils import conf
 
 QUEUE = "pytest"
 
@@ -62,17 +63,20 @@ def test_enqueue_job_runs_job(self, worker):
         assert job.state == State.COMPLETED
 
     def test_enqueue_job_runs_job_once(self, worker, flag):
-        b = Worker(worker.storage.engine, regular_workers=1, high_workers=1)
-        job = Job(toggle_flag, args=(flag.event_id,))
-        worker.storage.enqueue_job(job, QUEUE)
-
-        while job.state != State.COMPLETED:
-            job = worker.storage.get_job(job.job_id)
-            time.sleep(0.5)
-
-        assert job.state == State.COMPLETED
-        assert flag.is_set()
-        b.shutdown()
+        # Do conditional check in here, as it seems to not work properly
+        # inside a pytest.mark.skipIf
+        if conf.OPTIONS["Database"]["DATABASE_ENGINE"] == "postgres":
+            b = Worker(worker.storage.engine, regular_workers=1, high_workers=1)
+            job = Job(toggle_flag, args=(flag.event_id,))
+            worker.storage.enqueue_job(job, QUEUE)
+
+            while job.state != State.COMPLETED:
+                job = worker.storage.get_job(job.job_id)
+                time.sleep(0.5)
+
+            assert job.state == State.COMPLETED
+            assert flag.is_set()
+            b.shutdown()
 
     def test_can_handle_unicode_exceptions(self, worker):
         # Make sure task exception info is not an object, but is either a string or None.
diff --git a/kolibri/plugins/learn/assets/test/views/topics-page.spec.js b/kolibri/plugins/learn/assets/test/views/topics-page.spec.js
--- a/kolibri/plugins/learn/assets/test/views/topics-page.spec.js
+++ b/kolibri/plugins/learn/assets/test/views/topics-page.spec.js
@@ -138,7 +138,28 @@ describe('TopicsPage', () => {
   describe('showing cards', () => {
     let wrapper;
     beforeEach(() => {
-      store.state.topicsTree.contents = [{ kind: ContentNodeKinds.TOPIC }];
+      store.state.topicsTree.contents = [
+        {
+          kind: ContentNodeKinds.TOPIC,
+          is_leaf: false,
+          children: {
+            results: [
+              { kind: ContentNodeKinds.VIDEO, is_leaf: true },
+              { kind: ContentNodeKinds.VIDEO, is_leaf: true },
+            ],
+          },
+        },
+        {
+          kind: ContentNodeKinds.TOPIC,
+          is_leaf: false,
+          children: {
+            results: [
+              { kind: ContentNodeKinds.VIDEO, is_leaf: true },
+              { kind: ContentNodeKinds.VIDEO, is_leaf: true },
+            ],
+          },
+        },
+      ];
       wrapper = shallowMount(TopicsPage, {
         store: store,
         localVue,