Repository: learningequality/kolibri

Issue:
Load content node progress for search results, and content node display in topics pages
## Observed behavior
We are currently not loading any content node progress data for content nodes displayed from search results, or from topic tree loading

## Expected behavior
Regardless of the provenance of ContentNodes we should load progress data for them.

## User-facing consequences
Inconsistent experience. Assumption that they have not engaged with certain content when they really have!



Discussion:
Note that for topics display, we are loading progress data currently, but only for the content nodes that are the direct children of the currently displayed topic (which for the most part, are topics, and hence we are not displaying cards for them, so it's a waste).

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
@@ -165,6 +165,35 @@ class CharInFilter(BaseInFilter, CharFilter):
     pass
 
 
+contentnode_filter_fields = [
+    "parent",
+    "parent__isnull",
+    "prerequisite_for",
+    "has_prerequisite",
+    "related",
+    "exclude_content_ids",
+    "ids",
+    "content_id",
+    "channel_id",
+    "kind",
+    "include_coach_content",
+    "kind_in",
+    "contains_quiz",
+    "grade_levels",
+    "resource_types",
+    "learning_activities",
+    "accessibility_labels",
+    "categories",
+    "learner_needs",
+    "keywords",
+    "channels",
+    "languages",
+    "tree_id",
+    "lft__gt",
+    "rght__lt",
+]
+
+
 class ContentNodeFilter(IdFilter):
     kind = ChoiceFilter(
         method="filter_kind",
@@ -191,33 +220,7 @@ class ContentNodeFilter(IdFilter):
 
     class Meta:
         model = models.ContentNode
-        fields = [
-            "parent",
-            "parent__isnull",
-            "prerequisite_for",
-            "has_prerequisite",
-            "related",
-            "exclude_content_ids",
-            "ids",
-            "content_id",
-            "channel_id",
-            "kind",
-            "include_coach_content",
-            "kind_in",
-            "contains_quiz",
-            "grade_levels",
-            "resource_types",
-            "learning_activities",
-            "accessibility_labels",
-            "categories",
-            "learner_needs",
-            "keywords",
-            "channels",
-            "languages",
-            "tree_id",
-            "lft__gt",
-            "rght__lt",
-        ]
+        fields = contentnode_filter_fields
 
     def filter_kind(self, queryset, name, value):
         """
@@ -488,6 +491,39 @@ def get_paginated_response_schema(self, schema):
         }
 
 
+def get_resume_queryset(request, queryset):
+    user = request.user
+    # if user is anonymous, don't return any nodes
+    # if person requesting is not the data they are requesting for, also return no nodes
+    if not user.is_facility_user:
+        queryset = queryset.none()
+    else:
+        # get the most recently viewed, but not finished, content nodes
+        # search for content nodes that currently exist in the database
+        content_ids = (
+            ContentSummaryLog.objects.filter(
+                content_id__in=models.ContentNode.objects.values_list(
+                    "content_id", flat=True
+                ).distinct()
+            )
+            .filter(user=user, progress__gt=0)
+            .exclude(progress=1)
+            .order_by("end_timestamp")
+            .values_list("content_id", flat=True)
+            .distinct()
+        )
+
+        # If no logs, don't bother doing the other queries
+        if not content_ids:
+            queryset = queryset.none()
+        else:
+            resume = queryset.filter_by_content_ids(
+                list(content_ids[:10]), validate=False
+            )
+            queryset = resume.dedupe_by_content_id(use_distinct=False)
+    return queryset
+
+
 @method_decorator(cache_forever, name="dispatch")
 class ContentNodeViewset(BaseContentNodeMixin, ReadOnlyValuesViewset):
     pagination_class = OptionalContentNodePagination
@@ -650,24 +686,19 @@ def recommendations_for(self, request, **kwargs):
         )
         return Response(self.serialize(queryset))
 
-    @detail_route(methods=["get"])
+    @list_route(methods=["get"])
     def next_steps(self, request, **kwargs):
         """
         Recommend content that has user completed content as a prerequisite, or leftward sibling.
-        Note that this is a slightly smelly use of a detail route, as the id in question is not for
-        a contentnode, but rather for a user. Recommend we move recommendation endpoints to their own
-        endpoints in future.
 
         :param request: request object
-        :param pk: id of the user whose recommendations they are
         :return: uncompleted content nodes, or empty queryset if user is anonymous
         """
         user = request.user
-        user_id = kwargs.get("pk", None)
         queryset = self.get_queryset()
         # if user is anonymous, don't return any nodes
         # if person requesting is not the data they are requesting for, also return no nodes
-        if not user.is_facility_user or user.id != user_id:
+        if not user.is_facility_user:
             queryset = queryset.none()
         else:
             completed_content_ids = ContentSummaryLog.objects.filter(
@@ -763,49 +794,15 @@ def popular(self, request, **kwargs):
 
         return Response(data)
 
-    @detail_route(methods=["get"])
+    @list_route(methods=["get"])
     def resume(self, request, **kwargs):
         """
         Recommend content that the user has recently engaged with, but not finished.
-        Note that this is a slightly smelly use of a detail route, as the id in question is not for
-        a contentnode, but rather for a user. Recommend we move recommendation endpoints to their own
-        endpoints in future.
 
         :param request: request object
-        :param pk: id of the user whose recommendations they are
         :return: 10 most recently viewed content nodes
         """
-        user = request.user
-        user_id = kwargs.get("pk", None)
-        queryset = self.get_queryset()
-        # if user is anonymous, don't return any nodes
-        # if person requesting is not the data they are requesting for, also return no nodes
-        if not user.is_facility_user or user.id != user_id:
-            queryset = queryset.none()
-        else:
-            # get the most recently viewed, but not finished, content nodes
-            # search for content nodes that currently exist in the database
-            content_ids = (
-                ContentSummaryLog.objects.filter(
-                    content_id__in=models.ContentNode.objects.values_list(
-                        "content_id", flat=True
-                    ).distinct()
-                )
-                .filter(user=user)
-                .exclude(progress=1)
-                .order_by("end_timestamp")
-                .values_list("content_id", flat=True)
-                .distinct()
-            )
-
-            # If no logs, don't bother doing the other queries
-            if not content_ids:
-                queryset = queryset.none()
-            else:
-                resume = queryset.filter_by_content_ids(
-                    list(content_ids[:10]), validate=False
-                )
-                queryset = resume.dedupe_by_content_id(use_distinct=False)
+        queryset = get_resume_queryset(request, self.get_queryset())
 
         return Response(self.serialize(queryset))
 
@@ -823,8 +820,7 @@ def resume(self, request, **kwargs):
 NUM_GRANDCHILDREN_PER_CHILD = 12
 
 
-@method_decorator(cache_forever, name="dispatch")
-class ContentNodeTreeViewset(BaseContentNodeMixin, BaseValuesViewset):
+class TreeQueryMixin(object):
     def validate_and_return_params(self, request):
         depth = request.query_params.get("depth", 2)
         next__gt = request.query_params.get("next__gt")
@@ -870,6 +866,32 @@ def get_grandchild_ids(self, child_ids, depth, page_size):
                     gc_by_parent[gc["parent_id"]].append(gc["id"])
                     yield gc["id"]
 
+    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
+        # does not exist (or exists but is not available).
+        parent_id = pk if pk and self.get_queryset().filter(id=pk).exists() else None
+
+        if parent_id is None:
+            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
+        ]
+
+        # 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)
+        )
+
+
+@method_decorator(cache_forever, name="dispatch")
+class ContentNodeTreeViewset(BaseContentNodeMixin, TreeQueryMixin, BaseValuesViewset):
     def retrieve(self, request, pk=None):
         """
         A nested, paginated representation of the children and grandchildren of a specific node
@@ -894,33 +916,11 @@ def retrieve(self, request, pk=None):
         :return: an object representing the parent with a pagination object as "children"
         """
 
-        # Get the model for the parent node here - we do this so that we trigger a 404 immediately if the node
-        # does not exist (or exists but is not available).
-        parent_id = pk if pk and self.get_queryset().filter(id=pk).exists() else None
-
-        if parent_id is None:
-            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
-        ]
-
-        # 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)
+        queryset = self.get_tree_queryset(request, pk)
 
         # We explicitly order by lft here, so that the nodes are in tree traversal order, so we can iterate over them and build
         # out our nested representation, being sure that any ancestors have already been processed.
-        nodes = self.serialize(
-            self.filter_queryset(self.get_queryset())
-            .filter(Q(id=parent_id) | Q(id__in=child_ids) | Q(id__in=gc_ids))
-            .order_by("lft")
-        )
+        nodes = self.serialize(queryset.order_by("lft"))
 
         # The serialized parent representation is the first node in the lft order
         parent = nodes[0]
@@ -934,7 +934,7 @@ def retrieve(self, request, pk=None):
         for desc in nodes[1:]:
             # First check to see whether it is a direct child of the
             # parent node that we initially queried
-            if desc["parent"] == parent_id:
+            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
@@ -1213,8 +1213,9 @@ def retrieve(self, request, pk):
         return Response(parent_data)
 
 
-class ContentNodeProgressFilter(IdFilter):
+class ContentNodeProgressFilter(ContentNodeFilter):
     lesson = UUIDFilter(method="filter_by_lesson")
+    resume = BooleanFilter(method="filter_by_resume")
 
     def filter_by_lesson(self, queryset, name, value):
         try:
@@ -1224,9 +1225,12 @@ def filter_by_lesson(self, queryset, name, value):
         except Lesson.DoesNotExist:
             return queryset.none()
 
+    def filter_by_resume(self, queryset, name, value):
+        return get_resume_queryset(self.request, queryset)
+
     class Meta:
         model = models.ContentNode
-        fields = ["ids", "parent", "lesson"]
+        fields = contentnode_filter_fields + ["resume", "lesson"]
 
 
 def mean(data):
@@ -1240,80 +1244,44 @@ def mean(data):
     return mean
 
 
-class ContentNodeProgressViewset(ReadOnlyValuesViewset):
+class ContentNodeProgressViewset(TreeQueryMixin, viewsets.GenericViewSet):
     filter_backends = (DjangoFilterBackend,)
     filter_class = ContentNodeProgressFilter
-
-    values = (
-        "id",
-        "kind",
-        "lft",
-        "rght",
-        "tree_id",
-        "content_id",
-    )
+    # Use same pagination class as ContentNodeViewset so we can
+    # return identically paginated responses.
+    # The only deviation is that we only return the results
+    # and not the full pagination object, as we expect
+    # that the pagination object generated by the ContentNodeViewset
+    # will be used to make subsequent page requests.
+    pagination_class = OptionalContentNodePagination
 
     def get_queryset(self):
-        return models.ContentNode.objects.all()
-
-    def consolidate(self, items, queryset):
+        return models.ContentNode.objects.filter(available=True)
 
-        leaves = (
-            queryset.get_descendants(include_self=True)
-            .order_by()
-            .exclude(available=False)
-            .exclude(kind=content_kinds.TOPIC)
-            .values("content_id", "lft", "rght", "tree_id")
+    def generate_response(self, request, queryset):
+        if request.user.is_anonymous():
+            return Response([])
+        logs = list(
+            ContentSummaryLog.objects.filter(
+                user=self.request.user,
+                content_id__in=queryset.exclude(kind=content_kinds.TOPIC).values_list(
+                    "content_id", flat=True
+                ),
+            ).values("content_id", "progress")
         )
+        return Response(logs)
 
-        leaf_content_ids = [c["content_id"] for c in leaves]
-
-        progress_lookup = {}
-
-        if not self.request.user.is_anonymous():
-            # if there are too many leaf_content_ids we'll have a
-            # 'too many SQL variables' errors in sqlite
-            # so we batch the leaf ids here:
-            total = len(leaf_content_ids)
-            i = 0
-            batch_size = 998
-            while i < total:
-                leaf_content_ids_batch = leaf_content_ids[i : i + batch_size]
-                logs = ContentSummaryLog.objects.filter(
-                    user=self.request.user, content_id__in=leaf_content_ids_batch
-                )
-                for log in logs.values("content_id", "progress"):
-                    progress_lookup[log["content_id"]] = round(log["progress"], 4)
-
-                i += batch_size
-
-        output = []
-
-        for item in items:
-            out_item = {
-                "id": item["id"],
-            }
-            if item["kind"] == content_kinds.TOPIC:
-                topic_leaves = filter(
-                    lambda x: x["lft"] > item["lft"]
-                    and x["rght"] < item["rght"]
-                    and x["tree_id"] == item["tree_id"],
-                    leaves,
-                )
+    def list(self, request, *args, **kwargs):
+        queryset = self.filter_queryset(self.get_queryset())
+        page_queryset = self.paginate_queryset(queryset)
+        if page_queryset is not None:
+            queryset = page_queryset
+        return self.generate_response(request, queryset)
 
-                out_item["progress_fraction"] = round(
-                    mean(
-                        progress_lookup.get(leaf["content_id"], 0)
-                        for leaf in topic_leaves
-                    ),
-                    4,
-                )
-            else:
-                out_item["progress_fraction"] = progress_lookup.get(
-                    item["content_id"], 0.0
-                )
-            output.append(out_item)
-        return output
+    @detail_route(methods=["get"])
+    def tree(self, request, pk=None):
+        queryset = self.get_tree_queryset(request, pk)
+        return self.generate_response(request, queryset)
 
 
 class FileViewset(viewsets.ReadOnlyModelViewSet):


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
@@ -1175,33 +1175,6 @@ def _setup_contentnode_progress(self):
 
         return facility, root, c1, c2, c2c1, c2c3
 
-    def test_contentnode_progress_detail_endpoint(self):
-
-        facility, root, c1, c2, c2c1, c2c3 = self._setup_contentnode_progress()
-
-        def assert_progress(node, progress):
-            response = self.client.get(
-                reverse(
-                    "kolibri:core:contentnodeprogress-detail", kwargs={"pk": node.id}
-                )
-            )
-            self.assertEqual(response.data["progress_fraction"], progress)
-
-        # check that there is no progress when not logged in
-        assert_progress(root, 0)
-        assert_progress(c1, 0)
-        assert_progress(c2, 0)
-        assert_progress(c2c1, 0)
-
-        # check that progress is calculated appropriately when user is logged in
-        self.client.login(username="learner", password="pass", facility=facility)
-
-        # The progress endpoint is used, so should report progress for topics
-        assert_progress(root, 0.24)
-        assert_progress(c1, 0)
-        assert_progress(c2, 0.4)
-        assert_progress(c2c1, 0.7)
-
     def test_contentnode_progress_list_endpoint(self):
 
         facility, root, c1, c2, c2c1, c2c3 = self._setup_contentnode_progress()
@@ -1209,25 +1182,18 @@ def test_contentnode_progress_list_endpoint(self):
         response = self.client.get(reverse("kolibri:core:contentnodeprogress-list"))
 
         def get_progress_fraction(node):
-            return list(filter(lambda x: x["id"] == node.id, response.data))[0][
-                "progress_fraction"
-            ]
+            return list(
+                filter(lambda x: x["content_id"] == node.content_id, response.data)
+            )[0]["progress"]
 
         # check that there is no progress when not logged in
-        self.assertEqual(get_progress_fraction(root), 0)
-        self.assertEqual(get_progress_fraction(c1), 0)
-        self.assertEqual(get_progress_fraction(c2), 0)
-        self.assertEqual(get_progress_fraction(c2c1), 0)
+        self.assertEqual(len(response.data), 0)
 
         # check that progress is calculated appropriately when user is logged in
         self.client.login(username="learner", password="pass", facility=facility)
 
         response = self.client.get(reverse("kolibri:core:contentnodeprogress-list"))
 
-        # The progress endpoint is used, so should report progress for topics
-        self.assertEqual(get_progress_fraction(root), 0.24)
-        self.assertEqual(get_progress_fraction(c1), 0)
-        self.assertEqual(get_progress_fraction(c2), 0.4)
         self.assertEqual(get_progress_fraction(c2c1), 0.7)
 
     def test_filtering_coach_content_anon(self):
@@ -1439,6 +1405,7 @@ def _create_summary_logs(self):
             user_id=user.id,
             start_timestamp=timezone.now(),
             kind="audio",
+            progress=0.5,
         )
         # create log with progress of 1
         # should not show up in api response
@@ -1459,6 +1426,7 @@ def _create_summary_logs(self):
             user_id=user.id,
             start_timestamp=timezone.now(),
             kind="content",
+            progress=0.5,
         )
         user.set_password(DUMMY_PASSWORD)
         user.save()
@@ -1467,27 +1435,14 @@ def _create_summary_logs(self):
     def test_resume(self):
         user, expected_content_ids = self._create_summary_logs()
         self.client.login(username=user.username, password=DUMMY_PASSWORD)
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-resume", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-resume"))
         response_content_ids = {node["content_id"] for node in response.json()}
         self.assertSetEqual(set(expected_content_ids), response_content_ids)
 
-    def test_resume_wrong_id(self):
-        user, expected_content_ids = self._create_summary_logs()
-        self.client.login(username=user.username, password=DUMMY_PASSWORD)
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-resume", kwargs={"pk": "wrong"})
-        )
-        response_content_ids = [node["content_id"] for node in response.json()]
-        self.assertEqual([], response_content_ids)
-
     def test_resume_zero_cache(self):
         user, expected_content_ids = self._create_summary_logs()
         self.client.login(username=user.username, password=DUMMY_PASSWORD)
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-resume", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-resume"))
         self.assertEqual(response["Cache-Control"], "max-age=0")
 
     def test_next_steps_prereq(self):
@@ -1507,9 +1462,7 @@ def test_next_steps_prereq(self):
         self.client.login(username=user.username, password=DUMMY_PASSWORD)
         post_req = root.prerequisite_for.first()
         expected_content_ids = (post_req.content_id,)
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-next-steps", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-next-steps"))
         response_content_ids = {node["content_id"] for node in response.json()}
         self.assertSetEqual(set(expected_content_ids), response_content_ids)
 
@@ -1528,32 +1481,9 @@ def test_next_steps_prereq_zero_cache(self):
         user.set_password(DUMMY_PASSWORD)
         user.save()
         self.client.login(username=user.username, password=DUMMY_PASSWORD)
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-next-steps", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-next-steps"))
         self.assertEqual(response["Cache-Control"], "max-age=0")
 
-    def test_next_steps_prereq_wrong_id(self):
-        facility = Facility.objects.create(name="MyFac")
-        user = FacilityUser.objects.create(username="user", facility=facility)
-        root = content.ContentNode.objects.get(title="root")
-        ContentSummaryLog.objects.create(
-            channel_id=root.channel_id,
-            content_id=root.content_id,
-            user_id=user.id,
-            progress=1,
-            start_timestamp=timezone.now(),
-            kind="audio",
-        )
-        user.set_password(DUMMY_PASSWORD)
-        user.save()
-        self.client.login(username=user.username, password=DUMMY_PASSWORD)
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-next-steps", kwargs={"pk": "wrong"})
-        )
-        response_content_ids = [node["content_id"] for node in response.json()]
-        self.assertEqual([], response_content_ids)
-
     def test_next_steps_prereq_in_progress(self):
         facility = Facility.objects.create(name="MyFac")
         user = FacilityUser.objects.create(username="user", facility=facility)
@@ -1579,9 +1509,7 @@ def test_next_steps_prereq_in_progress(self):
             kind="audio",
         )
         expected_content_ids = []
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-next-steps", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-next-steps"))
         response_content_ids = {node["content_id"] for node in response.json()}
         self.assertSetEqual(set(expected_content_ids), response_content_ids)
 
@@ -1603,9 +1531,7 @@ def test_next_steps_prereq_coach_content_not_coach(self):
         post_req = root.prerequisite_for.first()
         post_req.coach_content = True
         post_req.save()
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-next-steps", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-next-steps"))
         response_content_ids = {node["content_id"] for node in response.json()}
         self.assertSetEqual(set(), response_content_ids)
 
@@ -1629,9 +1555,7 @@ def test_next_steps_prereq_coach_content_coach(self):
         post_req.coach_content = True
         post_req.save()
         expected_content_ids = (post_req.content_id,)
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-next-steps", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-next-steps"))
         response_content_ids = {node["content_id"] for node in response.json()}
         self.assertSetEqual(set(expected_content_ids), response_content_ids)
 
@@ -1654,9 +1578,7 @@ def test_next_steps_sibling(self):
         self.client.login(username=user.username, password=DUMMY_PASSWORD)
         sibling = node.get_next_sibling()
         expected_content_ids = (sibling.content_id,)
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-next-steps", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-next-steps"))
         response_content_ids = {node["content_id"] for node in response.json()}
         self.assertSetEqual(set(expected_content_ids), response_content_ids)
 
@@ -1687,9 +1609,7 @@ def test_next_steps_sibling_in_progress(self):
             kind="audio",
         )
         expected_content_ids = []
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-next-steps", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-next-steps"))
         response_content_ids = {node["content_id"] for node in response.json()}
         self.assertSetEqual(set(expected_content_ids), response_content_ids)
 
@@ -1713,9 +1633,7 @@ def test_next_steps_sibling_coach_content_not_coach(self):
         sibling = node.get_next_sibling()
         sibling.coach_content = True
         sibling.save()
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-next-steps", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-next-steps"))
         response_content_ids = {node["content_id"] for node in response.json()}
         self.assertSetEqual(set(), response_content_ids)
 
@@ -1741,9 +1659,7 @@ def test_next_steps_sibling_coach_content_coach(self):
         sibling.coach_content = True
         sibling.save()
         expected_content_ids = (sibling.content_id,)
-        response = self.client.get(
-            reverse("kolibri:core:contentnode-next-steps", kwargs={"pk": user.id})
-        )
+        response = self.client.get(reverse("kolibri:core:contentnode-next-steps"))
         response_content_ids = {node["content_id"] for node in response.json()}
         self.assertSetEqual(set(expected_content_ids), response_content_ids)
 
diff --git a/kolibri/plugins/learn/assets/src/composables/__tests__/useLearnerResources.spec.js b/kolibri/plugins/learn/assets/src/composables/__tests__/useLearnerResources.spec.js
--- a/kolibri/plugins/learn/assets/src/composables/__tests__/useLearnerResources.spec.js
+++ b/kolibri/plugins/learn/assets/src/composables/__tests__/useLearnerResources.spec.js
@@ -17,7 +17,6 @@ const {
   getClassActiveLessons,
   getClassActiveQuizzes,
   getResumableContentNode,
-  getResumableContentNodeProgress,
   getClassLessonLink,
   getClassQuizLink,
   getClassResourceLink,
@@ -430,7 +429,7 @@ describe(`useLearnerResources`, () => {
   describe(`resumableNonClassesContentNodes`, () => {
     it(`returns an array of IDs of all content nodes that are
       in progress and don't belong to any of learner's classes`, () => {
-      expect(resumableNonClassesContentNodes.value).toEqual([
+      expect(resumableNonClassesContentNodes.value).toMatchObject([
         { id: 'resource-9-in-progress', title: 'Resource 9 (In Progress)' },
         { id: 'resource-10-in-progress', title: 'Resource 10 (In Progress)' },
       ]);
@@ -539,19 +538,13 @@ describe(`useLearnerResources`, () => {
 
   describe(`getResumableContentNode`, () => {
     it(`returns a resumable content node object by its ID`, () => {
-      expect(getResumableContentNode('resource-6-in-progress')).toEqual({
+      expect(getResumableContentNode('resource-6-in-progress')).toMatchObject({
         id: 'resource-6-in-progress',
         title: 'Resource 6 (In Progress)',
       });
     });
   });
 
-  describe(`getResumableContentNodeProgress`, () => {
-    it(`returns a resumable content node progress`, () => {
-      expect(getResumableContentNodeProgress('resource-3-in-progress')).toBe(0.74);
-    });
-  });
-
   describe(`getClassQuizLink`, () => {
     it(`returns a vue-router link to a quiz report page when the quiz is closed`, () => {
       expect(
@@ -621,11 +614,13 @@ describe(`useLearnerResources`, () => {
           classId: 'class-2',
         })
       ).toEqual({
-        name: ClassesPageNames.LESSON_RESOURCE_VIEWER,
+        name: PageNames.TOPICS_CONTENT,
         params: {
+          id: 'resource-1-in-progress',
+        },
+        query: {
           classId: 'class-2',
           lessonId: 'class-2-active-lesson-1',
-          resourceNumber: 2,
         },
       });
     });
diff --git a/kolibri/plugins/learn/assets/test/bookmark-page.spec.js b/kolibri/plugins/learn/assets/test/bookmark-page.spec.js
--- a/kolibri/plugins/learn/assets/test/bookmark-page.spec.js
+++ b/kolibri/plugins/learn/assets/test/bookmark-page.spec.js
@@ -27,14 +27,7 @@ describe('Bookmark Page', () => {
     createdSpy = jest.spyOn(BookmarkPage, 'created').mockImplementation(() => Promise.resolve());
     removeFromBookmarksSpy = jest.spyOn(BookmarkPage.methods, 'removeFromBookmarks');
 
-    wrapper = shallowMount(BookmarkPage, {
-      stubs: {
-        ContentCardGroupGrid: {
-          name: 'ContentCardGroupGrid',
-          template: '<div></div>',
-        },
-      },
-    });
+    wrapper = shallowMount(BookmarkPage);
     wrapper.setData({
       loading: false,
       more: true,
diff --git a/kolibri/plugins/learn/assets/test/views/completion-modal.spec.js b/kolibri/plugins/learn/assets/test/views/completion-modal.spec.js
--- a/kolibri/plugins/learn/assets/test/views/completion-modal.spec.js
+++ b/kolibri/plugins/learn/assets/test/views/completion-modal.spec.js
@@ -18,7 +18,7 @@ function getMoveOnButton(wrapper) {
   return wrapper.findComponent({ ref: 'nextContentNodeSection' }).findComponent({ ref: 'button' });
 }
 
-function makeWrapper({ propsData } = {}) {
+function makeWrapper({ propsData, data } = {}) {
   const router = new VueRouter();
   router.push('/');
 
@@ -26,12 +26,22 @@ function makeWrapper({ propsData } = {}) {
     localVue,
     router,
     propsData,
+    data,
+    methods: {
+      loadNextContent: jest.fn(),
+      loadRecommendedContent: jest.fn(),
+    },
   });
 }
 
 describe('CompletionModal', () => {
   it('smoke test', () => {
-    const wrapper = shallowMount(CompletionModal);
+    const wrapper = shallowMount(CompletionModal, {
+      methods: {
+        loadNextContent: jest.fn(),
+        loadRecommendedContent: jest.fn(),
+      },
+    });
 
     expect(wrapper.exists()).toBe(true);
   });
@@ -109,11 +119,7 @@ describe('CompletionModal', () => {
     let wrapper;
 
     beforeEach(() => {
-      wrapper = makeWrapper({
-        propsData: {
-          nextContentNode: undefined,
-        },
-      });
+      wrapper = makeWrapper();
     });
 
     it("doesn't display 'Keep going' section", () => {
@@ -126,9 +132,8 @@ describe('CompletionModal', () => {
 
     beforeEach(() => {
       wrapper = makeWrapper({
-        propsData: {
-          nextContentNode: { title: 'Next content node' },
-          nextContentNodeRoute: { path: '/next-content-node-path' },
+        data() {
+          return { nextContentNode: { title: 'Next content node', id: 'testetesttestst' } };
         },
       });
     });
@@ -138,13 +143,6 @@ describe('CompletionModal', () => {
       expect(wrapper.text()).toContain('Next content node');
       expect(getMoveOnButton(wrapper).exists()).toBe(true);
     });
-
-    it("navigates to the next resource page on 'Move on' button click", () => {
-      expect(wrapper.vm.$route.path).toBe('/');
-      getMoveOnButton(wrapper).trigger('click');
-
-      expect(wrapper.vm.$route.path).toBe('/next-content-node-path');
-    });
   });
 
   describe("when a resource doesn't have any recommended resources", () => {
@@ -168,12 +166,13 @@ describe('CompletionModal', () => {
 
     beforeEach(() => {
       wrapper = makeWrapper({
-        propsData: {
-          recommendedContentNodes: [
-            { id: 'recommended-resource-1', title: 'The first recommended resource' },
-            { id: 'recommended-resource-2', title: 'The second recommended resource ' },
-          ],
-          genContentLink: id => '/' + id + '-path',
+        data() {
+          return {
+            recommendedContentNodes: [
+              { id: 'recommended-resource-1', title: 'The first recommended resource' },
+              { id: 'recommended-resource-2', title: 'The second recommended resource ' },
+            ],
+          };
         },
       });
     });
@@ -191,13 +190,5 @@ describe('CompletionModal', () => {
       expect(recommendedResources.at(1).text()).toContain('The second recommended resource');
       expect(recommendedResources.at(1).element.tagName).toBe('A');
     });
-
-    it('navigates to the recommended resource page on the resource link click', () => {
-      expect(wrapper.vm.$route.path).toBe('/');
-      const recommendedResources = wrapper.findAll("[data-test='recommended-resource']");
-      recommendedResources.at(1).trigger('click');
-
-      expect(wrapper.vm.$route.path).toBe('/recommended-resource-2-path');
-    });
   });
 });