diff --git a/contentcuration/contentcuration/management/commands/create_channel_versions.py b/contentcuration/contentcuration/management/commands/create_channel_versions.py index 082947251b..90ec5787c8 100644 --- a/contentcuration/contentcuration/management/commands/create_channel_versions.py +++ b/contentcuration/contentcuration/management/commands/create_channel_versions.py @@ -194,8 +194,8 @@ def handle(self, *args, **options): # noqa: C901 "non_distributable_licenses_included" ), "kind_count": pub_data.get("kind_count"), - "size": int(channel.published_size), - "resource_count": channel.total_resource_count, + "size": int(pub_data.get("size", 0)), + "resource_count": int(pub_data.get("resource_count", 0)), }, ) diff --git a/contentcuration/contentcuration/migrations/0164_add_channel_info_snapshot_to_channelversion.py b/contentcuration/contentcuration/migrations/0164_add_channel_info_snapshot_to_channelversion.py new file mode 100644 index 0000000000..abbd2c7883 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0164_add_channel_info_snapshot_to_channelversion.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2.24 on 2026-03-24 18:10 +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contentcuration", "0163_merge_20260320_1809"), + ] + + operations = [ + migrations.AddField( + model_name="channelversion", + name="channel_description", + field=models.CharField(blank=True, max_length=400, null=True), + ), + migrations.AddField( + model_name="channelversion", + name="channel_language", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="contentcuration.language", + ), + ), + migrations.AddField( + model_name="channelversion", + name="channel_name", + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AddField( + model_name="channelversion", + name="channel_tagline", + field=models.CharField(blank=True, max_length=150, null=True), + ), + migrations.AddField( + model_name="channelversion", + name="channel_thumbnail_encoding", + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index fb226339f4..299317f828 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -1607,14 +1607,37 @@ class ChannelVersion(models.Model): blank=True, ) + # Snapshot of the channel info at the time of creation. + channel_name = models.CharField(max_length=200, blank=True, null=True) + channel_description = models.CharField(max_length=400, blank=True, null=True) + channel_tagline = models.CharField(max_length=150, blank=True, null=True) + channel_thumbnail_encoding = JSONField(default=dict, blank=True) + channel_language = models.ForeignKey( + "Language", + null=True, + blank=True, + related_name="+", + on_delete=models.SET_NULL, + ) + class Meta: unique_together = ("channel", "version") def save(self, *args, **kwargs): if self.version is not None and self.version > self.channel.version: raise ValidationError("Version cannot be greater than channel version") + + if self._state.adding and self.version == self.channel.version: + # When creating a new ChannelVersion for current channel version, + # snapshot the current channel info + self.channel_name = self.channel.name + self.channel_description = self.channel.description + self.channel_tagline = self.channel.tagline + self.channel_thumbnail_encoding = self.channel.thumbnail_encoding + self.channel_language = self.channel.language + self.full_clean() - super(ChannelVersion, self).save(*args, **kwargs) + super().save(*args, **kwargs) def new_token(self): if not self.secret_token: diff --git a/contentcuration/contentcuration/serializers.py b/contentcuration/contentcuration/serializers.py index c1a6082402..104677e887 100644 --- a/contentcuration/contentcuration/serializers.py +++ b/contentcuration/contentcuration/serializers.py @@ -22,6 +22,22 @@ def no_field_eval_repr(self): serializers.ModelSerializer.__repr__ = no_field_eval_repr +def get_thumbnail_encoding(channel): + """ + Historically, we did not set channel.icon_encoding in the Studio database. We + only set it in the exported Kolibri sqlite db. So when Kolibri asks for the channel + information, fall back to the channel thumbnail data if icon_encoding is not set. + """ + if channel.icon_encoding: + return channel.icon_encoding + if channel.thumbnail_encoding: + base64 = channel.thumbnail_encoding.get("base64") + if base64: + return base64 + + return None + + class PublicChannelSerializer(serializers.ModelSerializer): """ Called by the public API, primarily used by Kolibri. Contains information more specific to Kolibri's needs. @@ -41,19 +57,7 @@ def match_tokens(self, channel): ) def get_thumbnail_encoding(self, channel): - """ - Historically, we did not set channel.icon_encoding in the Studio database. We - only set it in the exported Kolibri sqlite db. So when Kolibri asks for the channel - information, fall back to the channel thumbnail data if icon_encoding is not set. - """ - if channel.icon_encoding: - return channel.icon_encoding - if channel.thumbnail_encoding: - base64 = channel.thumbnail_encoding.get("base64") - if base64: - return base64 - - return None + return get_thumbnail_encoding(channel) def generate_kind_count(self, channel): return channel.published_kind_count and json.loads(channel.published_kind_count) diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index d416d71f86..eaf4e0370b 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -31,6 +31,7 @@ from contentcuration.models import FlagFeedbackEvent from contentcuration.models import generate_object_storage_name from contentcuration.models import Invitation +from contentcuration.models import Language from contentcuration.models import License from contentcuration.models import object_storage_name from contentcuration.models import RecommendationsEvent @@ -1814,3 +1815,105 @@ def test_version_cannot_exceed_channel_version(self): self.assertIn( "Version cannot be greater than channel version", str(context.exception) ) + + def test_save_snapshots_channel_info_when_version_matches_channel_version(self): + """Creating a ChannelVersion whose version equals channel.version should + automatically snapshot the channel's current name, description, tagline, + thumbnail_encoding, and language.""" + lang = Language.objects.first() + # Use a queryset update to set channel fields without triggering on_update + # (which would call get_or_create and collide with the ChannelVersion we're + # about to create ourselves). + Channel.objects.filter(id=self.channel.id).update( + name="Snapshot Channel", + description="A channel to snapshot", + tagline="Learn something new", + thumbnail_encoding={"base64": "abc123"}, + language=lang, + ) + self.channel.refresh_from_db() + + # setUp's self.channel.save() already auto-created a ChannelVersion for + # version 10 via on_update. Delete it so we can create a fresh one and + # observe the snapshot logic. + ChannelVersion.objects.filter( + channel=self.channel, version=self.channel.version + ).delete() + + cv = ChannelVersion( + channel=self.channel, + version=self.channel.version, + ) + cv.save() + + cv.refresh_from_db() + self.assertEqual(cv.channel_name, self.channel.name) + self.assertEqual(cv.channel_description, self.channel.description) + self.assertEqual(cv.channel_tagline, self.channel.tagline) + self.assertEqual(cv.channel_thumbnail_encoding, self.channel.thumbnail_encoding) + self.assertEqual(cv.channel_language, self.channel.language) + + def test_save_does_not_snapshot_when_version_differs_from_channel_version(self): + """Creating a ChannelVersion for an older version should NOT populate + the snapshot fields automatically.""" + self.channel.name = "Current Name" + self.channel.save() + + # version 5 is less than channel.version (10) + cv = ChannelVersion( + channel=self.channel, + version=5, + ) + cv.save() + + cv.refresh_from_db() + self.assertIsNone(cv.channel_name) + self.assertIsNone(cv.channel_description) + self.assertIsNone(cv.channel_tagline) + self.assertIsNone(cv.channel_language) + + def test_save_does_not_re_snapshot_on_update(self): + """Updating an existing ChannelVersion (not adding) should NOT overwrite + the snapshot fields even if the channel info has changed.""" + # setUp's self.channel.save() already created a ChannelVersion for version 10 + # via on_update -> get_or_create. Reuse that existing object so we're + # testing a genuine update (not insert) path. + cv = ChannelVersion.objects.get( + channel=self.channel, version=self.channel.version + ) + original_name = cv.channel_name + + # Change the channel name via a queryset update so on_update is not called + # (avoiding a second get_or_create for the same version). + Channel.objects.filter(id=self.channel.id).update(name="Updated Channel Name") + self.channel.refresh_from_db() + + cv.version_notes = "some notes" + cv.save() + + cv.refresh_from_db() + # The snapshot should still reflect the name captured when cv was first created. + self.assertEqual(cv.channel_name, original_name) + self.assertNotEqual(cv.channel_name, "Updated Channel Name") + + def test_save_snapshots_null_language_when_channel_has_no_language(self): + """When the channel has no language set, channel_language on the snapshot + should remain None.""" + # Ensure no language on the channel via queryset update (bypasses on_update). + Channel.objects.filter(id=self.channel.id).update(language=None) + self.channel.refresh_from_db() + + # Delete the ChannelVersion auto-created during setUp so we can insert a + # fresh one and observe the snapshot logic. + ChannelVersion.objects.filter( + channel=self.channel, version=self.channel.version + ).delete() + + cv = ChannelVersion( + channel=self.channel, + version=self.channel.version, + ) + cv.save() + + cv.refresh_from_db() + self.assertIsNone(cv.channel_language) diff --git a/contentcuration/kolibri_public/tests/test_public_v1_api.py b/contentcuration/kolibri_public/tests/test_public_v1_api.py index 75037eba6a..72259d6607 100644 --- a/contentcuration/kolibri_public/tests/test_public_v1_api.py +++ b/contentcuration/kolibri_public/tests/test_public_v1_api.py @@ -2,6 +2,7 @@ from django.core.cache import cache from django.urls import reverse +from contentcuration.models import ChannelVersion from contentcuration.tests.base import BaseAPITestCase from contentcuration.tests.testdata import generated_base64encoding @@ -74,3 +75,149 @@ def test_public_channels_endpoint(self): self.assertEqual(first_channel["name"], self.channel.name) self.assertEqual(first_channel["id"], self.channel.id) self.assertEqual(first_channel["icon_encoding"], generated_base64encoding()) + + def test_public_channel_lookup_with_channel_version_token_uses_channel_version( + self, + ): + """ + A channel version token should resolve to the matched ChannelVersion, + not the channel's current published version. + """ + self.channel.main_tree.published = True + self.channel.main_tree.save() + + self.channel.version = 7 + self.channel.published_data = { + "2": {"version_notes": "v2 notes"}, + "4": {"version_notes": "v4 notes"}, + "7": {"version_notes": "v7 notes"}, + } + self.channel.save() + + 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}, + ) + response = self.client.get(lookup_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["version"], 4) + self.assertNotEqual(response.data[0]["version"], self.channel.version) + self.assertEqual( + response.data[0]["version_notes"], {2: "v2 notes", 4: "v4 notes"} + ) + + def test_public_channel_lookup_channel_version_and_channel_tokens_have_same_keys( + self, + ): + """ + Lookup responses from channel-version-token and channel-token endpoints + should expose the same top-level keys, even if values differ. + """ + self.channel.main_tree.published = True + self.channel.main_tree.save() + + self.channel.version = 9 + self.channel.published_data = { + "3": {"version_notes": "v3 notes"}, + "9": {"version_notes": "v9 notes"}, + } + self.channel.save() + + latest_channel_version, _created = ChannelVersion.objects.get_or_create( + channel=self.channel, + version=9, + defaults={ + "kind_count": [], + "included_languages": [], + "resource_count": 0, + "size": 0, + }, + ) + latest_version_token = latest_channel_version.new_token().token + channel_token = self.channel.make_token().token + + channel_version_response = self.client.get( + reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": latest_version_token}, + ) + ) + channel_response = self.client.get( + reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": channel_token}, + ) + ) + + self.assertEqual(channel_version_response.status_code, 200) + self.assertEqual(channel_response.status_code, 200) + self.assertEqual(len(channel_version_response.data), 1) + self.assertEqual(len(channel_response.data), 1) + + self.assertSetEqual( + set(channel_version_response.data[0].keys()), + set(channel_response.data[0].keys()), + ) + + def test_channel_version_token_returns_snapshot_info_not_current_channel_info(self): + """ + When a channel version token is used, the returned name, description, and + thumbnail should come from the ChannelVersion snapshot captured at publish time, + not from the channel's current (possibly updated) values. + """ + self.channel.main_tree.published = True + self.channel.main_tree.save() + + # Set the channel info BEFORE the ChannelVersion is created so that the + # snapshot captures these values. + self.channel.name = "Original Published Name" + self.channel.description = "Original published description" + self.channel.thumbnail_encoding = {"base64": generated_base64encoding()} + self.channel.version = 3 + self.channel.published_data = {"3": {"version_notes": "v3 notes"}} + self.channel.save() + + # The ChannelVersion for version == channel.version is auto-created by + # Channel.on_update(); re-fetch it to get the snapshot that was captured. + channel_version = ChannelVersion.objects.get(channel=self.channel, version=3) + version_token = channel_version.new_token().token + + # Now mutate the channel's info AFTER the snapshot was taken. + self.channel.name = "Updated Name — should NOT appear in response" + self.channel.description = "Updated description — should NOT appear in response" + self.channel.thumbnail_encoding = {"base64": "UPDATED_ENCODING"} + self.channel.save() + + lookup_url = reverse( + "get_public_channel_lookup", + kwargs={"version": "v1", "identifier": version_token}, + ) + response = self.client.get(lookup_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + result = response.data[0] + + # Values must match the snapshot, not the current channel state. + self.assertEqual(result["name"], "Original Published Name") + self.assertEqual(result["description"], "Original published description") + self.assertEqual(result["icon_encoding"], generated_base64encoding()) + + # Sanity-check: confirm the channel itself now has the updated values. + self.channel.refresh_from_db() + self.assertNotEqual(result["name"], self.channel.name) + self.assertNotEqual(result["description"], self.channel.description) diff --git a/contentcuration/kolibri_public/views_v1.py b/contentcuration/kolibri_public/views_v1.py index 1aec8b0ba6..5c37aa22eb 100644 --- a/contentcuration/kolibri_public/views_v1.py +++ b/contentcuration/kolibri_public/views_v1.py @@ -1,4 +1,5 @@ import json +from collections import OrderedDict from django.conf import settings from django.contrib.sites.models import Site @@ -19,6 +20,7 @@ from contentcuration.decorators import cache_no_user_data from contentcuration.models import Channel +from contentcuration.models import ChannelVersion from contentcuration.serializers import PublicChannelSerializer @@ -28,6 +30,49 @@ def _get_channel_list(version, params, identifier=None): raise LookupError() +def _get_version_notes(channel, channel_version): + data = { + int(k): v["version_notes"] + for k, v in channel.published_data.items() + if int(k) <= channel_version.version + } + return OrderedDict(sorted(data.items())) + + +def get_thumbnail_encoding(channel_version): + if channel_version.channel_thumbnail_encoding: + return channel_version.channel_thumbnail_encoding.get("base64") + return None + + +def _serialize_channel_version(channel_version_qs): + channel_version = channel_version_qs.first() + if not channel_version or not channel_version.channel: + return [] + + channel = channel_version.channel + return [ + { + "id": channel_version.channel_id, + "name": channel_version.channel_name, + "language": channel_version.channel_language_id, + "public": channel.public, + "description": channel_version.channel_description, + "icon_encoding": get_thumbnail_encoding(channel_version), + "version_notes": _get_version_notes(channel, channel_version), + "version": channel_version.version, + "kind_count": channel_version.kind_count, + "included_languages": channel_version.included_languages, + "total_resource_count": channel_version.resource_count, + "published_size": channel_version.size, + "last_published": channel_version.date_published, + "matching_tokens": [channel_version.secret_token.token] + if channel_version.secret_token + else [], + } + ] + + def _get_channel_list_v1(params, identifier=None): keyword = params.get("keyword", "").strip() language_id = params.get("language", "").strip() @@ -40,6 +85,20 @@ 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. + channel_version = ChannelVersion.objects.select_related( + "secret_token", "channel" + ).filter( + secret_token__token=identifier, + channel__deleted=False, + channel__main_tree__published=True, + ) + if channel_version.exists(): + # return early as we won't need to apply the other filters for channel version tokens + return channel_version else: channels = Channel.objects.prefetch_related("secret_tokens").filter( Q(public=True) | Q(secret_tokens__token__in=token_list) @@ -96,7 +155,12 @@ def get_public_channel_lookup(request, version, identifier): return HttpResponseNotFound( _("No channel matching {} found").format(escape(identifier)) ) - return Response(PublicChannelSerializer(channel_list, many=True).data) + + if channel_list.model == ChannelVersion: + channel_list = _serialize_channel_version(channel_list) + return Response(channel_list) + else: + return Response(PublicChannelSerializer(channel_list, many=True).data) @api_view(["GET"])