Skip to content

Commit e5631c8

Browse files
authored
Merge pull request #5828 from rtibblesbot/issue-5821-fac365
feat: add library field to v1 and v2 public channel APIs, gate v1 ChannelVersion lookup
2 parents 6e426dc + f3391be commit e5631c8

7 files changed

Lines changed: 333 additions & 7 deletions

File tree

contentcuration/contentcuration/serializers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
from collections import OrderedDict
33

4+
from le_utils.constants import library as library_constants
45
from rest_framework import serializers
56

67
from contentcuration.models import Channel
@@ -47,6 +48,7 @@ class PublicChannelSerializer(serializers.ModelSerializer):
4748
matching_tokens = serializers.SerializerMethodField("match_tokens")
4849
icon_encoding = serializers.SerializerMethodField("get_thumbnail_encoding")
4950
version_notes = serializers.SerializerMethodField("sort_published_data")
51+
library = serializers.SerializerMethodField()
5052

5153
def match_tokens(self, channel):
5254
tokens = json.loads(channel.tokens) if hasattr(channel, "tokens") else []
@@ -66,6 +68,9 @@ def sort_published_data(self, channel):
6668
data = {int(k): v["version_notes"] for k, v in channel.published_data.items()}
6769
return OrderedDict(sorted(data.items()))
6870

71+
def get_library(self, channel):
72+
return library_constants.KOLIBRI if channel.public else None
73+
6974
class Meta:
7075
model = Channel
7176
fields = (
@@ -83,6 +88,7 @@ class Meta:
8388
"matching_tokens",
8489
"public",
8590
"version_notes",
91+
"library",
8692
)
8793

8894

contentcuration/kolibri_public/tests/test_channelmetadata_viewset.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,3 +387,45 @@ def test_labels_language_objects_have_id_and_lang_name(self):
387387
for lang in response.data["languages"]:
388388
self.assertIn("id", lang)
389389
self.assertIn("lang_name", lang)
390+
391+
392+
class ChannelMetadataLibraryFieldTestCase(StudioAPITestCase):
393+
def setUp(self):
394+
super().setUp()
395+
self.mixer = KolibriPublicMixer()
396+
self.user = testdata.user("library@test.com")
397+
self.client.force_authenticate(self.user)
398+
399+
def test_public_channel_returns_library_kolibri(self):
400+
"""
401+
A public channel in the v2 API returns library: "KOLIBRI".
402+
"""
403+
channel = self.mixer.blend(ChannelMetadata, public=True)
404+
405+
response = self.client.get(
406+
reverse_with_query(
407+
"publicchannel-detail",
408+
args=[channel.id],
409+
query={"public": "true"},
410+
),
411+
)
412+
413+
self.assertEqual(response.status_code, 200, response.content)
414+
self.assertEqual(response.data["library"], "KOLIBRI")
415+
416+
def test_non_public_channel_returns_library_community(self):
417+
"""
418+
A non-public channel in the v2 API returns library: "COMMUNITY".
419+
"""
420+
channel = self.mixer.blend(ChannelMetadata, public=False)
421+
422+
response = self.client.get(
423+
reverse_with_query(
424+
"publicchannel-detail",
425+
args=[channel.id],
426+
query={"public": "false"},
427+
),
428+
)
429+
430+
self.assertEqual(response.status_code, 200, response.content)
431+
self.assertEqual(response.data["library"], "COMMUNITY")

contentcuration/kolibri_public/tests/test_public_v1_api.py

Lines changed: 258 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
from django.core.cache import cache
33
from django.urls import reverse
44

5+
from contentcuration.constants import community_library_submission as cls_constants
56
from contentcuration.models import ChannelVersion
7+
from contentcuration.models import CommunityLibrarySubmission
68
from contentcuration.tests.base import BaseAPITestCase
79
from contentcuration.tests.testdata import generated_base64encoding
810

@@ -110,7 +112,7 @@ def test_public_channel_lookup_with_channel_version_token_uses_channel_version(
110112
"get_public_channel_lookup",
111113
kwargs={"version": "v1", "identifier": version_token},
112114
)
113-
response = self.client.get(lookup_url)
115+
response = self.client.get(lookup_url + "?channel_versions=true")
114116

115117
self.assertEqual(response.status_code, 200)
116118
self.assertEqual(len(response.data), 1)
@@ -155,6 +157,7 @@ def test_public_channel_lookup_channel_version_and_channel_tokens_have_same_keys
155157
"get_public_channel_lookup",
156158
kwargs={"version": "v1", "identifier": latest_version_token},
157159
)
160+
+ "?channel_versions=true"
158161
)
159162
channel_response = self.client.get(
160163
reverse(
@@ -206,7 +209,7 @@ def test_channel_version_token_returns_snapshot_info_not_current_channel_info(se
206209
"get_public_channel_lookup",
207210
kwargs={"version": "v1", "identifier": version_token},
208211
)
209-
response = self.client.get(lookup_url)
212+
response = self.client.get(lookup_url + "?channel_versions=true")
210213

211214
self.assertEqual(response.status_code, 200)
212215
self.assertEqual(len(response.data), 1)
@@ -221,3 +224,256 @@ def test_channel_version_token_returns_snapshot_info_not_current_channel_info(se
221224
self.channel.refresh_from_db()
222225
self.assertNotEqual(result["name"], self.channel.name)
223226
self.assertNotEqual(result["description"], self.channel.description)
227+
228+
def test_channel_version_token_lookup_requires_channel_versions_param(self):
229+
"""
230+
Without channel_versions=true, a channel-version token must return 404.
231+
With channel_versions=true it must return 200 with the correct version.
232+
"""
233+
self.channel.main_tree.published = True
234+
self.channel.main_tree.save()
235+
self.channel.version = 4
236+
self.channel.published_data = {"4": {"version_notes": "v4 notes"}}
237+
self.channel.save()
238+
# Channel.on_update() auto-creates ChannelVersion(version=4) when channel.save() is called.
239+
# The get_or_create below finds that existing record; defaults are not applied.
240+
# new_token() creates the secret token if it doesn't already exist.
241+
channel_version, _created = ChannelVersion.objects.get_or_create(
242+
channel=self.channel,
243+
version=4,
244+
defaults={
245+
"kind_count": [],
246+
"included_languages": [],
247+
"resource_count": 0,
248+
"size": 0,
249+
},
250+
)
251+
version_token = channel_version.new_token().token
252+
253+
lookup_url = reverse(
254+
"get_public_channel_lookup",
255+
kwargs={"version": "v1", "identifier": version_token},
256+
)
257+
258+
# Without the param: must 404
259+
response = self.client.get(lookup_url)
260+
self.assertEqual(response.status_code, 404)
261+
262+
# With channel_versions=true: must 200 with the correct version
263+
response = self.client.get(lookup_url + "?channel_versions=true")
264+
self.assertEqual(response.status_code, 200)
265+
self.assertEqual(len(response.data), 1)
266+
self.assertEqual(response.data[0]["version"], 4)
267+
268+
def test_channel_version_token_without_param_returns_404(self):
269+
"""
270+
A channel-version token used without ?channel_versions=true returns 404.
271+
The gate must be active by default so older Kolibri clients never
272+
accidentally receive data they cannot parse correctly.
273+
"""
274+
self.channel.main_tree.published = True
275+
self.channel.main_tree.save()
276+
self.channel.version = 11
277+
self.channel.published_data = {"11": {"version_notes": "v11 notes"}}
278+
self.channel.save()
279+
280+
channel_version, _created = ChannelVersion.objects.get_or_create(
281+
channel=self.channel,
282+
version=11,
283+
defaults={
284+
"kind_count": [],
285+
"included_languages": [],
286+
"resource_count": 0,
287+
"size": 0,
288+
},
289+
)
290+
version_token = channel_version.new_token().token
291+
292+
lookup_url = reverse(
293+
"get_public_channel_lookup",
294+
kwargs={"version": "v1", "identifier": version_token},
295+
)
296+
297+
response = self.client.get(lookup_url)
298+
self.assertEqual(response.status_code, 404)
299+
300+
def test_channel_version_token_with_approved_submission_returns_library_community(
301+
self,
302+
):
303+
"""
304+
A channel-version token whose ChannelVersion has a CommunityLibrarySubmission
305+
with APPROVED status returns library: "COMMUNITY".
306+
"""
307+
self.channel.main_tree.published = True
308+
self.channel.main_tree.save()
309+
self.channel.version = 5
310+
self.channel.published_data = {"5": {"version_notes": "v5 notes"}}
311+
self.channel.save()
312+
313+
# CommunityLibrarySubmission.save() calls ChannelVersion.objects.get_or_create(version=5)
314+
# (finding the one already created by Channel.on_update()) and then calls new_token()
315+
# to create the secret token. self.user is already an editor of self.channel (from setUp).
316+
CommunityLibrarySubmission.objects.create(
317+
channel=self.channel,
318+
channel_version=5,
319+
author=self.user,
320+
status=cls_constants.STATUS_APPROVED,
321+
)
322+
323+
channel_version = ChannelVersion.objects.get(channel=self.channel, version=5)
324+
version_token = channel_version.secret_token.token
325+
326+
lookup_url = (
327+
reverse(
328+
"get_public_channel_lookup",
329+
kwargs={"version": "v1", "identifier": version_token},
330+
)
331+
+ "?channel_versions=true"
332+
)
333+
response = self.client.get(lookup_url)
334+
self.assertEqual(response.status_code, 200)
335+
self.assertEqual(response.data[0]["library"], "COMMUNITY")
336+
337+
def test_channel_version_token_with_live_submission_returns_library_community(self):
338+
"""
339+
A channel-version token whose ChannelVersion has a CommunityLibrarySubmission
340+
with LIVE status returns library: "COMMUNITY".
341+
"""
342+
self.channel.main_tree.published = True
343+
self.channel.main_tree.save()
344+
self.channel.version = 7
345+
self.channel.published_data = {"7": {"version_notes": "v7 notes"}}
346+
self.channel.save()
347+
348+
# CommunityLibrarySubmission.save() validates that self.channel.public is False
349+
# (it is False by default) and that self.user is a channel editor (added in setUp).
350+
# It also calls ChannelVersion.objects.get_or_create(version=7) and new_token().
351+
CommunityLibrarySubmission.objects.create(
352+
channel=self.channel,
353+
channel_version=7,
354+
author=self.user,
355+
status=cls_constants.STATUS_LIVE,
356+
)
357+
358+
channel_version = ChannelVersion.objects.get(channel=self.channel, version=7)
359+
version_token = channel_version.secret_token.token
360+
361+
lookup_url = (
362+
reverse(
363+
"get_public_channel_lookup",
364+
kwargs={"version": "v1", "identifier": version_token},
365+
)
366+
+ "?channel_versions=true"
367+
)
368+
response = self.client.get(lookup_url)
369+
self.assertEqual(response.status_code, 200)
370+
self.assertEqual(response.data[0]["library"], "COMMUNITY")
371+
372+
def test_channel_version_token_with_pending_submission_returns_library_null(self):
373+
"""
374+
A channel-version token whose ChannelVersion has a CommunityLibrarySubmission
375+
with PENDING status (not approved or live) returns library: null.
376+
This validates that the status filter in _get_channel_version_library is correct.
377+
"""
378+
self.channel.main_tree.published = True
379+
self.channel.main_tree.save()
380+
self.channel.version = 8
381+
self.channel.published_data = {"8": {"version_notes": "v8 notes"}}
382+
self.channel.save()
383+
384+
# CommunityLibrarySubmission with PENDING status should NOT qualify.
385+
CommunityLibrarySubmission.objects.create(
386+
channel=self.channel,
387+
channel_version=8,
388+
author=self.user,
389+
status=cls_constants.STATUS_PENDING,
390+
)
391+
392+
channel_version = ChannelVersion.objects.get(channel=self.channel, version=8)
393+
version_token = channel_version.secret_token.token
394+
395+
lookup_url = (
396+
reverse(
397+
"get_public_channel_lookup",
398+
kwargs={"version": "v1", "identifier": version_token},
399+
)
400+
+ "?channel_versions=true"
401+
)
402+
response = self.client.get(lookup_url)
403+
self.assertEqual(response.status_code, 200)
404+
self.assertIsNone(response.data[0]["library"])
405+
406+
def test_channel_version_token_without_submission_returns_library_null(self):
407+
"""
408+
A channel-version token with no associated CommunityLibrarySubmission
409+
returns library: null.
410+
"""
411+
self.channel.main_tree.published = True
412+
self.channel.main_tree.save()
413+
self.channel.version = 6
414+
self.channel.published_data = {"6": {"version_notes": "v6 notes"}}
415+
self.channel.save()
416+
417+
# Channel.on_update() creates ChannelVersion(version=6); get_or_create finds it.
418+
# No CommunityLibrarySubmission is created, so no token is auto-generated.
419+
# new_token() creates the secret token here.
420+
channel_version, _created = ChannelVersion.objects.get_or_create(
421+
channel=self.channel,
422+
version=6,
423+
defaults={
424+
"kind_count": [],
425+
"included_languages": [],
426+
"resource_count": 0,
427+
"size": 0,
428+
},
429+
)
430+
version_token = channel_version.new_token().token
431+
432+
lookup_url = (
433+
reverse(
434+
"get_public_channel_lookup",
435+
kwargs={"version": "v1", "identifier": version_token},
436+
)
437+
+ "?channel_versions=true"
438+
)
439+
response = self.client.get(lookup_url)
440+
self.assertEqual(response.status_code, 200)
441+
self.assertIsNone(response.data[0]["library"])
442+
443+
def test_public_channel_token_returns_library_kolibri(self):
444+
"""
445+
A regular channel token for a public channel returns library: "KOLIBRI".
446+
"""
447+
self.channel.public = True
448+
self.channel.main_tree.published = True
449+
self.channel.main_tree.save()
450+
self.channel.save()
451+
452+
channel_token = self.channel.make_token().token
453+
454+
lookup_url = reverse(
455+
"get_public_channel_lookup",
456+
kwargs={"version": "v1", "identifier": channel_token},
457+
)
458+
response = self.client.get(lookup_url)
459+
self.assertEqual(response.status_code, 200)
460+
self.assertEqual(response.data[0]["library"], "KOLIBRI")
461+
462+
def test_non_public_channel_token_returns_library_null(self):
463+
"""
464+
A regular channel token for a non-public channel returns library: null.
465+
"""
466+
self.channel.public = False
467+
self.channel.main_tree.published = True
468+
self.channel.main_tree.save()
469+
self.channel.save()
470+
471+
channel_token = self.channel.make_token().token
472+
473+
lookup_url = reverse(
474+
"get_public_channel_lookup",
475+
kwargs={"version": "v1", "identifier": channel_token},
476+
)
477+
response = self.client.get(lookup_url)
478+
self.assertEqual(response.status_code, 200)
479+
self.assertIsNone(response.data[0]["library"])

contentcuration/kolibri_public/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from kolibri_public.search import get_contentnode_available_metadata_labels
3838
from kolibri_public.stopwords import stopwords_set
3939
from le_utils.constants import content_kinds
40+
from le_utils.constants import library as library_constants
4041
from rest_framework import status
4142
from rest_framework.decorators import action
4243
from rest_framework.filters import SearchFilter
@@ -252,6 +253,13 @@ def consolidate(self, items, queryset):
252253
item["countries"] = countries.get(item["id"], [])
253254
item["token"] = channel_tokens.get(item["id"])
254255
item["last_published"] = item["last_updated"]
256+
# v2 non-public channels are always community library channels (unlike v1
257+
# channel tokens, which return null for non-public channels).
258+
item["library"] = (
259+
library_constants.KOLIBRI
260+
if item["public"]
261+
else library_constants.COMMUNITY
262+
)
255263

256264
return items
257265

0 commit comments

Comments
 (0)