Skip to content

Commit 49cf302

Browse files
authored
Merge pull request #5772 from AlexVelezLl/support-channel-version-tokens
Add support for channel version tokens on v1 public channel endpoint
2 parents 5e2f4f1 + 16a9af8 commit 49cf302

7 files changed

Lines changed: 403 additions & 17 deletions

File tree

contentcuration/contentcuration/management/commands/create_channel_versions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,8 @@ def handle(self, *args, **options): # noqa: C901
194194
"non_distributable_licenses_included"
195195
),
196196
"kind_count": pub_data.get("kind_count"),
197-
"size": int(channel.published_size),
198-
"resource_count": channel.total_resource_count,
197+
"size": int(pub_data.get("size", 0)),
198+
"resource_count": int(pub_data.get("resource_count", 0)),
199199
},
200200
)
201201

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated by Django 3.2.24 on 2026-03-24 18:10
2+
import django.db.models.deletion
3+
from django.db import migrations
4+
from django.db import models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("contentcuration", "0163_merge_20260320_1809"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="channelversion",
16+
name="channel_description",
17+
field=models.CharField(blank=True, max_length=400, null=True),
18+
),
19+
migrations.AddField(
20+
model_name="channelversion",
21+
name="channel_language",
22+
field=models.ForeignKey(
23+
blank=True,
24+
null=True,
25+
on_delete=django.db.models.deletion.SET_NULL,
26+
related_name="+",
27+
to="contentcuration.language",
28+
),
29+
),
30+
migrations.AddField(
31+
model_name="channelversion",
32+
name="channel_name",
33+
field=models.CharField(blank=True, max_length=200, null=True),
34+
),
35+
migrations.AddField(
36+
model_name="channelversion",
37+
name="channel_tagline",
38+
field=models.CharField(blank=True, max_length=150, null=True),
39+
),
40+
migrations.AddField(
41+
model_name="channelversion",
42+
name="channel_thumbnail_encoding",
43+
field=models.JSONField(blank=True, default=dict),
44+
),
45+
]

contentcuration/contentcuration/models.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1607,14 +1607,37 @@ class ChannelVersion(models.Model):
16071607
blank=True,
16081608
)
16091609

1610+
# Snapshot of the channel info at the time of creation.
1611+
channel_name = models.CharField(max_length=200, blank=True, null=True)
1612+
channel_description = models.CharField(max_length=400, blank=True, null=True)
1613+
channel_tagline = models.CharField(max_length=150, blank=True, null=True)
1614+
channel_thumbnail_encoding = JSONField(default=dict, blank=True)
1615+
channel_language = models.ForeignKey(
1616+
"Language",
1617+
null=True,
1618+
blank=True,
1619+
related_name="+",
1620+
on_delete=models.SET_NULL,
1621+
)
1622+
16101623
class Meta:
16111624
unique_together = ("channel", "version")
16121625

16131626
def save(self, *args, **kwargs):
16141627
if self.version is not None and self.version > self.channel.version:
16151628
raise ValidationError("Version cannot be greater than channel version")
1629+
1630+
if self._state.adding and self.version == self.channel.version:
1631+
# When creating a new ChannelVersion for current channel version,
1632+
# snapshot the current channel info
1633+
self.channel_name = self.channel.name
1634+
self.channel_description = self.channel.description
1635+
self.channel_tagline = self.channel.tagline
1636+
self.channel_thumbnail_encoding = self.channel.thumbnail_encoding
1637+
self.channel_language = self.channel.language
1638+
16161639
self.full_clean()
1617-
super(ChannelVersion, self).save(*args, **kwargs)
1640+
super().save(*args, **kwargs)
16181641

16191642
def new_token(self):
16201643
if not self.secret_token:

contentcuration/contentcuration/serializers.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ def no_field_eval_repr(self):
2222
serializers.ModelSerializer.__repr__ = no_field_eval_repr
2323

2424

25+
def get_thumbnail_encoding(channel):
26+
"""
27+
Historically, we did not set channel.icon_encoding in the Studio database. We
28+
only set it in the exported Kolibri sqlite db. So when Kolibri asks for the channel
29+
information, fall back to the channel thumbnail data if icon_encoding is not set.
30+
"""
31+
if channel.icon_encoding:
32+
return channel.icon_encoding
33+
if channel.thumbnail_encoding:
34+
base64 = channel.thumbnail_encoding.get("base64")
35+
if base64:
36+
return base64
37+
38+
return None
39+
40+
2541
class PublicChannelSerializer(serializers.ModelSerializer):
2642
"""
2743
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):
4157
)
4258

4359
def get_thumbnail_encoding(self, channel):
44-
"""
45-
Historically, we did not set channel.icon_encoding in the Studio database. We
46-
only set it in the exported Kolibri sqlite db. So when Kolibri asks for the channel
47-
information, fall back to the channel thumbnail data if icon_encoding is not set.
48-
"""
49-
if channel.icon_encoding:
50-
return channel.icon_encoding
51-
if channel.thumbnail_encoding:
52-
base64 = channel.thumbnail_encoding.get("base64")
53-
if base64:
54-
return base64
55-
56-
return None
60+
return get_thumbnail_encoding(channel)
5761

5862
def generate_kind_count(self, channel):
5963
return channel.published_kind_count and json.loads(channel.published_kind_count)

contentcuration/contentcuration/tests/test_models.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from contentcuration.models import FlagFeedbackEvent
3232
from contentcuration.models import generate_object_storage_name
3333
from contentcuration.models import Invitation
34+
from contentcuration.models import Language
3435
from contentcuration.models import License
3536
from contentcuration.models import object_storage_name
3637
from contentcuration.models import RecommendationsEvent
@@ -1814,3 +1815,105 @@ def test_version_cannot_exceed_channel_version(self):
18141815
self.assertIn(
18151816
"Version cannot be greater than channel version", str(context.exception)
18161817
)
1818+
1819+
def test_save_snapshots_channel_info_when_version_matches_channel_version(self):
1820+
"""Creating a ChannelVersion whose version equals channel.version should
1821+
automatically snapshot the channel's current name, description, tagline,
1822+
thumbnail_encoding, and language."""
1823+
lang = Language.objects.first()
1824+
# Use a queryset update to set channel fields without triggering on_update
1825+
# (which would call get_or_create and collide with the ChannelVersion we're
1826+
# about to create ourselves).
1827+
Channel.objects.filter(id=self.channel.id).update(
1828+
name="Snapshot Channel",
1829+
description="A channel to snapshot",
1830+
tagline="Learn something new",
1831+
thumbnail_encoding={"base64": "abc123"},
1832+
language=lang,
1833+
)
1834+
self.channel.refresh_from_db()
1835+
1836+
# setUp's self.channel.save() already auto-created a ChannelVersion for
1837+
# version 10 via on_update. Delete it so we can create a fresh one and
1838+
# observe the snapshot logic.
1839+
ChannelVersion.objects.filter(
1840+
channel=self.channel, version=self.channel.version
1841+
).delete()
1842+
1843+
cv = ChannelVersion(
1844+
channel=self.channel,
1845+
version=self.channel.version,
1846+
)
1847+
cv.save()
1848+
1849+
cv.refresh_from_db()
1850+
self.assertEqual(cv.channel_name, self.channel.name)
1851+
self.assertEqual(cv.channel_description, self.channel.description)
1852+
self.assertEqual(cv.channel_tagline, self.channel.tagline)
1853+
self.assertEqual(cv.channel_thumbnail_encoding, self.channel.thumbnail_encoding)
1854+
self.assertEqual(cv.channel_language, self.channel.language)
1855+
1856+
def test_save_does_not_snapshot_when_version_differs_from_channel_version(self):
1857+
"""Creating a ChannelVersion for an older version should NOT populate
1858+
the snapshot fields automatically."""
1859+
self.channel.name = "Current Name"
1860+
self.channel.save()
1861+
1862+
# version 5 is less than channel.version (10)
1863+
cv = ChannelVersion(
1864+
channel=self.channel,
1865+
version=5,
1866+
)
1867+
cv.save()
1868+
1869+
cv.refresh_from_db()
1870+
self.assertIsNone(cv.channel_name)
1871+
self.assertIsNone(cv.channel_description)
1872+
self.assertIsNone(cv.channel_tagline)
1873+
self.assertIsNone(cv.channel_language)
1874+
1875+
def test_save_does_not_re_snapshot_on_update(self):
1876+
"""Updating an existing ChannelVersion (not adding) should NOT overwrite
1877+
the snapshot fields even if the channel info has changed."""
1878+
# setUp's self.channel.save() already created a ChannelVersion for version 10
1879+
# via on_update -> get_or_create. Reuse that existing object so we're
1880+
# testing a genuine update (not insert) path.
1881+
cv = ChannelVersion.objects.get(
1882+
channel=self.channel, version=self.channel.version
1883+
)
1884+
original_name = cv.channel_name
1885+
1886+
# Change the channel name via a queryset update so on_update is not called
1887+
# (avoiding a second get_or_create for the same version).
1888+
Channel.objects.filter(id=self.channel.id).update(name="Updated Channel Name")
1889+
self.channel.refresh_from_db()
1890+
1891+
cv.version_notes = "some notes"
1892+
cv.save()
1893+
1894+
cv.refresh_from_db()
1895+
# The snapshot should still reflect the name captured when cv was first created.
1896+
self.assertEqual(cv.channel_name, original_name)
1897+
self.assertNotEqual(cv.channel_name, "Updated Channel Name")
1898+
1899+
def test_save_snapshots_null_language_when_channel_has_no_language(self):
1900+
"""When the channel has no language set, channel_language on the snapshot
1901+
should remain None."""
1902+
# Ensure no language on the channel via queryset update (bypasses on_update).
1903+
Channel.objects.filter(id=self.channel.id).update(language=None)
1904+
self.channel.refresh_from_db()
1905+
1906+
# Delete the ChannelVersion auto-created during setUp so we can insert a
1907+
# fresh one and observe the snapshot logic.
1908+
ChannelVersion.objects.filter(
1909+
channel=self.channel, version=self.channel.version
1910+
).delete()
1911+
1912+
cv = ChannelVersion(
1913+
channel=self.channel,
1914+
version=self.channel.version,
1915+
)
1916+
cv.save()
1917+
1918+
cv.refresh_from_db()
1919+
self.assertIsNone(cv.channel_language)

0 commit comments

Comments
 (0)