From 7f92a0810d9771868b26ee811273f00494526d7d Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Thu, 24 Sep 2026 13:10:57 -0700 Subject: [PATCH] feat: paginate importmetadata on max_results and include descendants Matches Kolibri's endpoint so course imports can fetch metadata from Studio. Closes #6170 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../kolibri_public/import_metadata_view.py | 19 +++++- .../tests/test_importmetadata_api.py | 61 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/contentcuration/kolibri_public/import_metadata_view.py b/contentcuration/kolibri_public/import_metadata_view.py index 9cf6e00952..5bb71ab8d5 100644 --- a/contentcuration/kolibri_public/import_metadata_view.py +++ b/contentcuration/kolibri_public/import_metadata_view.py @@ -24,6 +24,8 @@ from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet +from contentcuration.utils.pagination import ValuesViewsetCursorPagination + def _get_kc_and_base_models(model): try: @@ -41,12 +43,19 @@ def _get_kc_and_base_models(model): return kc_model, base_model +class ImportMetadataPagination(ValuesViewsetCursorPagination): + # All nodes in one request share a tree, so lft needs no id tie-break. + ordering = ("lft",) + page_size_query_param = "max_results" + + # Add the standard metadata_cache decorator to this endpoint to align # with other public endpoints @method_decorator(metadata_cache, name="dispatch") class ImportMetadataViewset(GenericViewSet): # Add an explicit allow any permission class to override the Studio default permission_classes = (AllowAny,) + pagination_class = ImportMetadataPagination default_content_schema = CONTENT_SCHEMA_VERSION min_content_schema = MIN_CONTENT_SCHEMA_VERSION @@ -106,7 +115,13 @@ def retrieve(self, request, pk=None): # noqa: C901 # does not exist. node = get_object_or_404(models.ContentNode.objects.all(), pk=pk) - nodes = node.get_ancestors(include_self=True) + if request.query_params.get("descendants"): + nodes = node.get_family() + else: + nodes = node.get_ancestors(include_self=True) + page = self.paginate_queryset(nodes.only("id", "lft")) + if page is not None: + nodes = page data = {} @@ -200,4 +215,6 @@ def retrieve(self, request, pk=None): # noqa: C901 data["schema_version"] = content_schema + if page is not None: + return self.get_paginated_response(data) return Response(data) diff --git a/contentcuration/kolibri_public/tests/test_importmetadata_api.py b/contentcuration/kolibri_public/tests/test_importmetadata_api.py index e56ab28629..c661b6cb39 100644 --- a/contentcuration/kolibri_public/tests/test_importmetadata_api.py +++ b/contentcuration/kolibri_public/tests/test_importmetadata_api.py @@ -13,6 +13,8 @@ from le_utils.constants import content_kinds from rest_framework.test import APITestCase +from contentcuration.tests.helpers import reverse_with_query + class ImportMetadataTestCase(APITestCase): @classmethod @@ -38,6 +40,11 @@ def setUpTestData(cls): cls.tags = public.ContentTag.objects.filter( id__in=cls.through_tags.values_list("contenttag_id", flat=True) ).distinct() + cls.topic = cls.node.parent + cls.ancestor_ids = list( + cls.topic.get_ancestors(include_self=True).values_list("id", flat=True) + ) + cls.family_ids = list(cls.topic.get_family().values_list("id", flat=True)) def _assert_data(self, Model, ContentModel, queryset): response = self.client.get( @@ -64,6 +71,60 @@ def _assert_data(self, Model, ContentModel, queryset): value = field.from_db_value(value, None, connection) self.assertEqual(value, getattr(obj, field.column)) + def _node_ids(self, data): + return [row["id"] for row in data[content.ContentNode._meta.db_table]] + + def _get_topic(self, query): + return self.client.get( + reverse_with_query( + "publicimportmetadata-detail", + kwargs={"pk": self.topic.id}, + query=query, + ) + ) + + def _get_paged_node_ids(self, query): + page_size = int(query["max_results"]) + node_ids = [] + for _ in self.family_ids: + response = self._get_topic(query) + self.assertEqual(set(response.data), {"more", "results"}) + page = response.data["results"] + page_node_ids = self._node_ids(page) + self.assertLessEqual(len(page_node_ids), page_size) + self.assertEqual( + {f["contentnode_id"] for f in page[content.File._meta.db_table]}, + set(page_node_ids), + ) + node_ids.extend(page_node_ids) + query = response.data["more"] + if query is None: + return node_ids + self.fail("more never became None") + + def test_import_metadata_unpaginated(self): + for query, expected in ( + ({}, self.ancestor_ids), + ({"max_results": "0"}, self.ancestor_ids), + ({"max_results": "abc"}, self.ancestor_ids), + ({"descendants": "true"}, self.family_ids), + ): + with self.subTest(query=query): + response = self._get_topic(query) + self.assertEqual(response.status_code, 200) + self.assertEqual(self._node_ids(response.data), expected) + + def test_import_metadata_paginated(self): + self.assertEqual( + self._get_paged_node_ids({"max_results": 3}), self.ancestor_ids + ) + + def test_import_metadata_paginated_descendants(self): + self.assertEqual( + self._get_paged_node_ids({"max_results": 2, "descendants": "true"}), + self.family_ids, + ) + def test_import_metadata_nodes(self): self._assert_data(public.ContentNode, content.ContentNode, self.all_nodes)