From 3dc898cbb6f87072c7d406e57af45a6bb7870031 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 18 Aug 2026 12:43:13 -0700 Subject: [PATCH 1/2] feat: expose Community Library channel versions without an account A version whose Community Library submission is live is now readable by anyone. Every other version stays scoped to the channel's editors, viewers and admins, so private and Kolibri Library channels are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P3rKRVLMe9qtMVMHG1N8N9 --- contentcuration/contentcuration/models.py | 18 ++++- .../tests/viewsets/test_channel.py | 73 +++++++++++++++++++ .../contentcuration/viewsets/channel.py | 2 +- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index a1918cdd85..ced4623e8f 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -1702,13 +1702,27 @@ def new_token(self): @classmethod def filter_view_queryset(cls, queryset, user): + live_in_community_library = Q( + Exists( + CommunityLibrarySubmission.objects.filter( + channel=OuterRef("channel"), + channel_version=OuterRef("version"), + status=community_library_submission.STATUS_LIVE, + ) + ) + ) + if user.is_anonymous: - return queryset.none() + return queryset.filter(live_in_community_library) if user.is_admin: return queryset - return queryset.filter(Q(channel__viewers=user) | Q(channel__editors=user)) + return queryset.filter( + live_in_community_library + | Q(channel__viewers=user) + | Q(channel__editors=user) + ) @classmethod def filter_edit_queryset(cls, queryset, user): diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index d36866aca7..8421848da3 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -1717,3 +1717,76 @@ def test_non_channel_viewer_cannot_access_channel_versions(self): response = self.client.get(url) results = response.json() self.assertEqual(len(results), 0) + + def make_community_library_submission(self, channel, version, status): + submission = CommunityLibrarySubmission.objects.create( + channel=channel, + channel_version=version, + author=self.user, + ) + submission.status = status + submission.save() + return submission + + def test_anonymous_user_cannot_access_non_community_channel_versions(self): + """Test that an anonymous user gets an empty list rather than a permission error.""" + self.client.force_authenticate(user=None) + url = reverse("channelversion-list") + f"?channel={self.channel.id}" + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 0) + + def test_anonymous_user_can_access_live_community_library_version(self): + """Test that an anonymous user can read the version live in the Community Library.""" + self.make_community_library_submission( + self.channel, 2, community_library_submission.STATUS_LIVE + ) + self.client.force_authenticate(user=None) + url = reverse("channelversion-list") + f"?channel={self.channel.id}" + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + results = response.json() + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["version"], 2) + + def test_anonymous_user_cannot_access_unpublished_community_library_version(self): + """Test that submissions that never went live stay hidden from anonymous users.""" + for status in ( + community_library_submission.STATUS_PENDING, + community_library_submission.STATUS_APPROVED, + community_library_submission.STATUS_REJECTED, + community_library_submission.STATUS_SUPERSEDED, + ): + with self.subTest(status=status): + submission = self.make_community_library_submission( + self.channel, 2, status + ) + self.client.force_authenticate(user=None) + url = reverse("channelversion-list") + f"?channel={self.channel.id}" + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 0) + submission.delete() + + def test_non_channel_viewer_can_access_live_community_library_version(self): + """Test that a signed-in non-editor can read the live Community Library version.""" + self.make_community_library_submission( + self.channel, 2, community_library_submission.STATUS_LIVE + ) + other_user = testdata.user(email="otheruser@example.com") + self.client.force_authenticate(user=other_user) + url = reverse("channelversion-list") + f"?channel={self.channel.id}" + response = self.client.get(url) + results = response.json() + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["version"], 2) + + def test_live_community_library_version_does_not_expose_other_versions(self): + """Test that going live exposes only that version, not the channel's other versions.""" + self.make_community_library_submission( + self.channel, 2, community_library_submission.STATUS_LIVE + ) + self.client.force_authenticate(user=None) + url = reverse("channelversion-list") + f"?channel={self.channel_2.id}" + response = self.client.get(url) + self.assertEqual(len(response.json()), 0) diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 238224226c..0d13e99515 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -1078,7 +1078,7 @@ class Meta: class ChannelVersionViewSet(ReadOnlyValuesViewset): queryset = ChannelVersion.objects.all() - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] pagination_class = ChannelVersionListPagination filterset_class = ChannelVersionFilter ordering_fields = ["version"] From c88081659b6556ac57db5a5c8f873a6d45c60dae Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 18 Aug 2026 12:43:24 -0700 Subject: [PATCH 2/2] feat: show Community Library alongside Kolibri Library for signed-out users Both library tabs now render without an account, and the contribute banner is hidden for visitors who have no channels to submit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P3rKRVLMe9qtMVMHG1N8N9 --- .../Channel/CommunityLibraryList/index.vue | 4 +++ .../channelList/views/ChannelListIndex.vue | 31 +++++++++++++------ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/CommunityLibraryList/index.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/CommunityLibraryList/index.vue index 7a81d67d1b..9eb2400f7f 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/CommunityLibraryList/index.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/CommunityLibraryList/index.vue @@ -41,6 +41,7 @@ @close="isAboutCommunityLibraryOpen = false" />
+