Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions contentcuration/contentcuration/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 []
Expand All @@ -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 = (
Expand All @@ -83,6 +88,7 @@ class Meta:
"matching_tokens",
"public",
"version_notes",
"library",
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
260 changes: 258 additions & 2 deletions contentcuration/kolibri_public/tests/test_public_v1_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really expected to see a test confirming that without the channel_versions query parameter, the ChannelVersion token would not produce any result. Please add.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_channel_version_token_without_param_returns_404 — a focused standalone test that uses a channel-version token without ?channel_versions=true and asserts a 404 response. Kept it separate from the combined gate test so the negative case is unambiguously visible on its own.

"""
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"])
8 changes: 8 additions & 0 deletions contentcuration/kolibri_public/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading