diff --git a/contentcuration/contentcuration/serializers.py b/contentcuration/contentcuration/serializers.py index 104677e887..618b25e66d 100644 --- a/contentcuration/contentcuration/serializers.py +++ b/contentcuration/contentcuration/serializers.py @@ -1,6 +1,7 @@ import json from collections import OrderedDict +from le_utils.constants import library as library_constants from rest_framework import serializers from contentcuration.models import Channel @@ -47,6 +48,7 @@ class PublicChannelSerializer(serializers.ModelSerializer): matching_tokens = serializers.SerializerMethodField("match_tokens") icon_encoding = serializers.SerializerMethodField("get_thumbnail_encoding") version_notes = serializers.SerializerMethodField("sort_published_data") + library = serializers.SerializerMethodField() def match_tokens(self, channel): tokens = json.loads(channel.tokens) if hasattr(channel, "tokens") else [] @@ -66,6 +68,9 @@ def sort_published_data(self, channel): data = {int(k): v["version_notes"] for k, v in channel.published_data.items()} return OrderedDict(sorted(data.items())) + def get_library(self, channel): + return library_constants.KOLIBRI if channel.public else None + class Meta: model = Channel fields = ( @@ -83,6 +88,7 @@ class Meta: "matching_tokens", "public", "version_notes", + "library", ) diff --git a/contentcuration/kolibri_public/tests/test_channelmetadata_viewset.py b/contentcuration/kolibri_public/tests/test_channelmetadata_viewset.py index a0a20e0a82..0fad180b2b 100644 --- a/contentcuration/kolibri_public/tests/test_channelmetadata_viewset.py +++ b/contentcuration/kolibri_public/tests/test_channelmetadata_viewset.py @@ -387,3 +387,45 @@ def test_labels_language_objects_have_id_and_lang_name(self): for lang in response.data["languages"]: self.assertIn("id", lang) self.assertIn("lang_name", lang) + + +class ChannelMetadataLibraryFieldTestCase(StudioAPITestCase): + def setUp(self): + super().setUp() + self.mixer = KolibriPublicMixer() + self.user = testdata.user("library@test.com") + self.client.force_authenticate(self.user) + + def test_public_channel_returns_library_kolibri(self): + """ + A public channel in the v2 API returns library: "KOLIBRI". + """ + channel = self.mixer.blend(ChannelMetadata, public=True) + + response = self.client.get( + reverse_with_query( + "publicchannel-detail", + args=[channel.id], + query={"public": "true"}, + ), + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.data["library"], "KOLIBRI") + + def test_non_public_channel_returns_library_community(self): + """ + A non-public channel in the v2 API returns library: "COMMUNITY". + """ + channel = self.mixer.blend(ChannelMetadata, public=False) + + response = self.client.get( + reverse_with_query( + "publicchannel-detail", + args=[channel.id], + query={"public": "false"}, + ), + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.data["library"], "COMMUNITY") diff --git a/contentcuration/kolibri_public/tests/test_public_v1_api.py b/contentcuration/kolibri_public/tests/test_public_v1_api.py index 72259d6607..38464ce54b 100644 --- a/contentcuration/kolibri_public/tests/test_public_v1_api.py +++ b/contentcuration/kolibri_public/tests/test_public_v1_api.py @@ -2,7 +2,9 @@ from django.core.cache import cache from django.urls import reverse +from contentcuration.constants import community_library_submission as cls_constants from contentcuration.models import ChannelVersion +from contentcuration.models import CommunityLibrarySubmission from contentcuration.tests.base import BaseAPITestCase from contentcuration.tests.testdata import generated_base64encoding @@ -110,7 +112,7 @@ def test_public_channel_lookup_with_channel_version_token_uses_channel_version( "get_public_channel_lookup", kwargs={"version": "v1", "identifier": version_token}, ) - response = self.client.get(lookup_url) + response = self.client.get(lookup_url + "?channel_versions=true") self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) @@ -155,6 +157,7 @@ def test_public_channel_lookup_channel_version_and_channel_tokens_have_same_keys "get_public_channel_lookup", kwargs={"version": "v1", "identifier": latest_version_token}, ) + + "?channel_versions=true" ) channel_response = self.client.get( reverse( @@ -206,7 +209,7 @@ def test_channel_version_token_returns_snapshot_info_not_current_channel_info(se "get_public_channel_lookup", kwargs={"version": "v1", "identifier": version_token}, ) - response = self.client.get(lookup_url) + response = self.client.get(lookup_url + "?channel_versions=true") self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) @@ -221,3 +224,256 @@ def test_channel_version_token_returns_snapshot_info_not_current_channel_info(se self.channel.refresh_from_db() self.assertNotEqual(result["name"], self.channel.name) self.assertNotEqual(result["description"], self.channel.description) + + def test_channel_version_token_lookup_requires_channel_versions_param(self): + """ + Without channel_versions=true, a channel-version token must return 404. + With channel_versions=true it must return 200 with the correct version. + """ + self.channel.main_tree.published = True + self.channel.main_tree.save() + self.channel.version = 4 + self.channel.published_data = {"4": {"version_notes": "v4 notes"}} + self.channel.save() + # Channel.on_update() auto-creates ChannelVersion(version=4) when channel.save() is called. + # The get_or_create below finds that existing record; defaults are not applied. + # new_token() creates the secret token if it doesn't already exist. + channel_version, _created = ChannelVersion.objects.get_or_create( + channel=self.channel, + version=4, + defaults={ + "kind_count": [], + "included_languages": [], + "resource_count": 0, + "size": 0, + }, + ) + version_token = channel_version.new_token().token + + lookup_url = reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": version_token}, + ) + + # Without the param: must 404 + response = self.client.get(lookup_url) + self.assertEqual(response.status_code, 404) + + # With channel_versions=true: must 200 with the correct version + response = self.client.get(lookup_url + "?channel_versions=true") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["version"], 4) + + def test_channel_version_token_without_param_returns_404(self): + """ + A channel-version token used without ?channel_versions=true returns 404. + The gate must be active by default so older Kolibri clients never + accidentally receive data they cannot parse correctly. + """ + self.channel.main_tree.published = True + self.channel.main_tree.save() + self.channel.version = 11 + self.channel.published_data = {"11": {"version_notes": "v11 notes"}} + self.channel.save() + + channel_version, _created = ChannelVersion.objects.get_or_create( + channel=self.channel, + version=11, + defaults={ + "kind_count": [], + "included_languages": [], + "resource_count": 0, + "size": 0, + }, + ) + version_token = channel_version.new_token().token + + lookup_url = reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": version_token}, + ) + + response = self.client.get(lookup_url) + self.assertEqual(response.status_code, 404) + + def test_channel_version_token_with_approved_submission_returns_library_community( + self, + ): + """ + A channel-version token whose ChannelVersion has a CommunityLibrarySubmission + with APPROVED status returns library: "COMMUNITY". + """ + self.channel.main_tree.published = True + self.channel.main_tree.save() + self.channel.version = 5 + self.channel.published_data = {"5": {"version_notes": "v5 notes"}} + self.channel.save() + + # CommunityLibrarySubmission.save() calls ChannelVersion.objects.get_or_create(version=5) + # (finding the one already created by Channel.on_update()) and then calls new_token() + # to create the secret token. self.user is already an editor of self.channel (from setUp). + CommunityLibrarySubmission.objects.create( + channel=self.channel, + channel_version=5, + author=self.user, + status=cls_constants.STATUS_APPROVED, + ) + + channel_version = ChannelVersion.objects.get(channel=self.channel, version=5) + version_token = channel_version.secret_token.token + + lookup_url = ( + reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": version_token}, + ) + + "?channel_versions=true" + ) + response = self.client.get(lookup_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["library"], "COMMUNITY") + + def test_channel_version_token_with_live_submission_returns_library_community(self): + """ + A channel-version token whose ChannelVersion has a CommunityLibrarySubmission + with LIVE status returns library: "COMMUNITY". + """ + self.channel.main_tree.published = True + self.channel.main_tree.save() + self.channel.version = 7 + self.channel.published_data = {"7": {"version_notes": "v7 notes"}} + self.channel.save() + + # CommunityLibrarySubmission.save() validates that self.channel.public is False + # (it is False by default) and that self.user is a channel editor (added in setUp). + # It also calls ChannelVersion.objects.get_or_create(version=7) and new_token(). + CommunityLibrarySubmission.objects.create( + channel=self.channel, + channel_version=7, + author=self.user, + status=cls_constants.STATUS_LIVE, + ) + + channel_version = ChannelVersion.objects.get(channel=self.channel, version=7) + version_token = channel_version.secret_token.token + + lookup_url = ( + reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": version_token}, + ) + + "?channel_versions=true" + ) + response = self.client.get(lookup_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["library"], "COMMUNITY") + + def test_channel_version_token_with_pending_submission_returns_library_null(self): + """ + A channel-version token whose ChannelVersion has a CommunityLibrarySubmission + with PENDING status (not approved or live) returns library: null. + This validates that the status filter in _get_channel_version_library is correct. + """ + self.channel.main_tree.published = True + self.channel.main_tree.save() + self.channel.version = 8 + self.channel.published_data = {"8": {"version_notes": "v8 notes"}} + self.channel.save() + + # CommunityLibrarySubmission with PENDING status should NOT qualify. + CommunityLibrarySubmission.objects.create( + channel=self.channel, + channel_version=8, + author=self.user, + status=cls_constants.STATUS_PENDING, + ) + + channel_version = ChannelVersion.objects.get(channel=self.channel, version=8) + version_token = channel_version.secret_token.token + + lookup_url = ( + reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": version_token}, + ) + + "?channel_versions=true" + ) + response = self.client.get(lookup_url) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data[0]["library"]) + + def test_channel_version_token_without_submission_returns_library_null(self): + """ + A channel-version token with no associated CommunityLibrarySubmission + returns library: null. + """ + self.channel.main_tree.published = True + self.channel.main_tree.save() + self.channel.version = 6 + self.channel.published_data = {"6": {"version_notes": "v6 notes"}} + self.channel.save() + + # Channel.on_update() creates ChannelVersion(version=6); get_or_create finds it. + # No CommunityLibrarySubmission is created, so no token is auto-generated. + # new_token() creates the secret token here. + channel_version, _created = ChannelVersion.objects.get_or_create( + channel=self.channel, + version=6, + defaults={ + "kind_count": [], + "included_languages": [], + "resource_count": 0, + "size": 0, + }, + ) + version_token = channel_version.new_token().token + + lookup_url = ( + reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": version_token}, + ) + + "?channel_versions=true" + ) + response = self.client.get(lookup_url) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data[0]["library"]) + + def test_public_channel_token_returns_library_kolibri(self): + """ + A regular channel token for a public channel returns library: "KOLIBRI". + """ + self.channel.public = True + self.channel.main_tree.published = True + self.channel.main_tree.save() + self.channel.save() + + channel_token = self.channel.make_token().token + + lookup_url = reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": channel_token}, + ) + response = self.client.get(lookup_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["library"], "KOLIBRI") + + def test_non_public_channel_token_returns_library_null(self): + """ + A regular channel token for a non-public channel returns library: null. + """ + self.channel.public = False + self.channel.main_tree.published = True + self.channel.main_tree.save() + self.channel.save() + + channel_token = self.channel.make_token().token + + lookup_url = reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": channel_token}, + ) + response = self.client.get(lookup_url) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data[0]["library"]) diff --git a/contentcuration/kolibri_public/views.py b/contentcuration/kolibri_public/views.py index a84bfad049..6fe7446de4 100644 --- a/contentcuration/kolibri_public/views.py +++ b/contentcuration/kolibri_public/views.py @@ -37,6 +37,7 @@ from kolibri_public.search import get_contentnode_available_metadata_labels from kolibri_public.stopwords import stopwords_set from le_utils.constants import content_kinds +from le_utils.constants import library as library_constants from rest_framework import status from rest_framework.decorators import action from rest_framework.filters import SearchFilter @@ -252,6 +253,13 @@ def consolidate(self, items, queryset): item["countries"] = countries.get(item["id"], []) item["token"] = channel_tokens.get(item["id"]) item["last_published"] = item["last_updated"] + # v2 non-public channels are always community library channels (unlike v1 + # channel tokens, which return null for non-public channels). + item["library"] = ( + library_constants.KOLIBRI + if item["public"] + else library_constants.COMMUNITY + ) return items diff --git a/contentcuration/kolibri_public/views_v1.py b/contentcuration/kolibri_public/views_v1.py index 5c37aa22eb..c3ad838af0 100644 --- a/contentcuration/kolibri_public/views_v1.py +++ b/contentcuration/kolibri_public/views_v1.py @@ -11,6 +11,7 @@ from django.utils.translation import gettext_lazy as _ from django.views.decorators.cache import cache_page from kolibri_content.constants.schema_versions import MIN_CONTENT_SCHEMA_VERSION +from le_utils.constants import library as library_constants from le_utils.uuidv5 import generate_ecosystem_namespaced_uuid from rest_framework import viewsets from rest_framework.decorators import api_view @@ -18,6 +19,7 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response +from contentcuration.constants import community_library_submission as cls_constants from contentcuration.decorators import cache_no_user_data from contentcuration.models import Channel from contentcuration.models import ChannelVersion @@ -45,6 +47,16 @@ def get_thumbnail_encoding(channel_version): return None +def _get_channel_version_library(channel_version): + channel = channel_version.channel + if channel.community_library_submissions.filter( + channel_version=channel_version.version, + status__in=[cls_constants.STATUS_APPROVED, cls_constants.STATUS_LIVE], + ).exists(): + return library_constants.COMMUNITY + return None + + def _serialize_channel_version(channel_version_qs): channel_version = channel_version_qs.first() if not channel_version or not channel_version.channel: @@ -69,6 +81,7 @@ def _serialize_channel_version(channel_version_qs): "matching_tokens": [channel_version.secret_token.token] if channel_version.secret_token else [], + "library": _get_channel_version_library(channel_version), } ] @@ -86,9 +99,10 @@ def _get_channel_list_v1(params, identifier=None): if not channels.exists(): channels = Channel.objects.filter(pk=identifier) - if not channels.exists(): - # If channels doesnt exist with the given token, check if this is a token of - # a channel version. + if not channels.exists() and params.get("channel_versions") == "true": + # Only resolve ChannelVersion tokens when the caller explicitly opts in. + # This prevents older Kolibri clients from accidentally retrieving data + # they cannot parse correctly. channel_version = ChannelVersion.objects.select_related( "secret_token", "channel" ).filter( diff --git a/requirements.in b/requirements.in index c38cd30797..fb23c221b3 100644 --- a/requirements.in +++ b/requirements.in @@ -5,7 +5,7 @@ djangorestframework==3.15.1 psycopg2-binary==2.9.11 django-js-reverse==0.10.2 django-registration==3.4 -le-utils==0.2.14 +le-utils==0.2.17 gunicorn==25.1.0 django-postmark==0.1.6 jsonfield==3.1.0 diff --git a/requirements.txt b/requirements.txt index 8ed6d01764..04ef76f211 100644 --- a/requirements.txt +++ b/requirements.txt @@ -177,7 +177,7 @@ langcodes==3.5.1 # via -r requirements.in latex2mathml==3.78.1 # via -r requirements.in -le-utils==0.2.14 +le-utils==0.2.17 # via -r requirements.in markdown-it-py==4.0.0 # via -r requirements.in