From d0b294be0ee3ef447b072adf64819173d06fa172 Mon Sep 17 00:00:00 2001 From: Marcella Maki Date: Tue, 21 Jul 2026 17:01:11 -0400 Subject: [PATCH 1/9] Add ability to send a notification email on review of community library submission --- contentcuration/contentcuration/models.py | 51 +++++++++++++++++++ .../submission_resolved_email.html | 27 ++++++++++ .../test_community_library_submission.py | 16 ++++++ .../viewsets/community_library_submission.py | 2 + 4 files changed, 96 insertions(+) create mode 100644 contentcuration/contentcuration/templates/community_library/submission_resolved_email.html diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 1f0c1ec8c5..bcf5b9bdc2 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -48,6 +48,8 @@ from django.db.models.query_utils import DeferredAttribute from django.db.models.sql import Query from django.dispatch import receiver +from django.template.loader import render_to_string +from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ from django_cte import CTEManager @@ -86,7 +88,9 @@ from contentcuration.db.models.manager import CustomContentNodeTreeManager from contentcuration.db.models.manager import CustomManager from contentcuration.utils.cache import delete_public_channel_cache_keys +from contentcuration.utils.messages import get_messages from contentcuration.utils.parser import load_json_string +from contentcuration.utils.urls import canonical_url from contentcuration.viewsets.sync.constants import ALL_CHANGES from contentcuration.viewsets.sync.constants import ALL_TABLES from contentcuration.viewsets.sync.constants import PUBLISHABLE_CHANGE_TABLES @@ -3049,6 +3053,53 @@ def notify_update_to_channel_editors(self, exclude_user_id=None): User.notify_users(editors, date=self.date_updated) + def send_resolution_email(self): + """ + Send an email to the submission author letting them know their + Community Library submission has been resolved (approved or + rejected). + """ + is_approved = self.status == community_library_submission.STATUS_APPROVED + + community_strings = get_messages().get("CommunityChannelsStrings", {}) + status_message = ( + community_strings["approvedStatus"] + if is_approved + else community_strings["flaggedStatus"] + ) + subject_text = "{}: {}".format( + community_strings["communityLibrarySubmissionLabel"], status_message + ) + + subject = render_to_string( + "registration/custom_email_subject.txt", + {"subject": subject_text}, + ) + subject = "".join(subject.splitlines()) + + message = render_to_string( + "community_library/submission_resolved_email.html", + { + "name": self.author.get_full_name(), + "channel": self.channel, + "channel_url": canonical_url( + reverse("channel", kwargs={"channel_id": self.channel.pk}) + ), + "approved": is_approved, + "status_message": ( + community_strings["availableStatus"] + if is_approved + else community_strings["needsChangesPrimaryInfo"] + ), + "feedback_notes_label": community_strings["feedbackNotesLabel"], + "feedback_notes": self.feedback_notes, + }, + ) + + self.author.email_user( + subject, message, settings.DEFAULT_FROM_EMAIL, html_message=message + ) + @classmethod def filter_view_queryset(cls, queryset, user): if user.is_anonymous: diff --git a/contentcuration/contentcuration/templates/community_library/submission_resolved_email.html b/contentcuration/contentcuration/templates/community_library/submission_resolved_email.html new file mode 100644 index 0000000000..0c187e947a --- /dev/null +++ b/contentcuration/contentcuration/templates/community_library/submission_resolved_email.html @@ -0,0 +1,27 @@ + +{% load i18n %} + + + + + + + {% autoescape off %} +

{% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %}

+ +

{% blocktrans with channel_name=channel.name %}{{ channel_name }}{% endblocktrans %} ({{ channel_url }})

+ +

{{ status_message }}

+ + {% if feedback_notes %} +

{{ feedback_notes_label }}: {{ feedback_notes }}

+ {% endif %} + +

+ {% translate "Thanks for using Kolibri Studio!" %} +
+ {% translate "The Learning Equality Team" %} +

+ {% endautoescape %} + + diff --git a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py index 4cd51fb2a1..9c960149ad 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py +++ b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py @@ -2,6 +2,7 @@ from unittest import mock import pytz +from django.core import mail from django.urls import reverse from contentcuration.constants import ( @@ -731,6 +732,13 @@ def test_resolve_submission__accept_correct(self, apply_task_mock): channel_id=self.submission.channel.id, ) + self.assertEqual(len(mail.outbox), 1) + sent_email = mail.outbox[0] + self.assertEqual(sent_email.to, [self.submission.author.email]) + self.assertIn("approved", sent_email.subject.lower()) + self.assertIn("available in community library", sent_email.body.lower()) + self.assertIn(self.submission.channel.name, sent_email.body) + @mock.patch( "contentcuration.viewsets.community_library_submission.apply_channel_changes_task" ) @@ -770,6 +778,14 @@ def test_resolve_submission__reject_correct(self, apply_task_mock): ) apply_task_mock.fetch_or_enqueue.assert_not_called() + self.assertEqual(len(mail.outbox), 1) + sent_email = mail.outbox[0] + self.assertEqual(sent_email.to, [self.submission.author.email]) + self.assertIn("needs changes", sent_email.subject.lower()) + self.assertIn("needs changes", sent_email.body.lower()) + self.assertIn(self.submission.channel.name, sent_email.body) + self.assertIn(self.feedback_notes, sent_email.body) + def test_resolve_submission__reject_missing_resolution_reason(self): self.client.force_authenticate(user=self.admin_user) metadata = self.resolve_reject_metadata.copy() diff --git a/contentcuration/contentcuration/viewsets/community_library_submission.py b/contentcuration/contentcuration/viewsets/community_library_submission.py index 33fc6f9a94..ff9723f92f 100644 --- a/contentcuration/contentcuration/viewsets/community_library_submission.py +++ b/contentcuration/contentcuration/viewsets/community_library_submission.py @@ -358,4 +358,6 @@ def resolve(self, request, pk=None): published_version.id ) + submission.send_resolution_email() + return Response(self.serialize_object()) From 5e176a5d2818ac9d22aea83d61e2ddba92558e4c Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 31 Jul 2026 14:52:09 -0500 Subject: [PATCH 2/9] fix: dedupe ChannelVersion.included_licenses in API responses Some ChannelVersion rows were backfilled with duplicate license IDs by a past one-off migration command, causing repeated license chips/names to show in the community library submission and review UIs. Dedupe defensively in the two viewset read paths that serve this field, rather than backfilling the affected rows. Co-Authored-By: Claude Sonnet 5 --- .../tests/viewsets/test_channel.py | 35 +++++++++++++++++++ .../contentcuration/viewsets/channel.py | 17 +++++++++ 2 files changed, 52 insertions(+) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 2c72cf2dcc..d36866aca7 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -1478,6 +1478,21 @@ def test_get_version_detail_returns_all_fields(self): for field in expected_fields: self.assertIn(field, data, f"Field '{field}' should be in response") + def test_get_version_detail_dedupes_duplicated_licenses(self): + """Test that duplicated license ids stored on the ChannelVersion (e.g. from a + backfill bug) are deduped in the response.""" + self.channel_version.included_licenses = [1, 2, 2, 1] + self.channel_version.non_distributable_licenses_included = [1, 1] + self.channel_version.save() + + url = reverse("channel-version-detail", kwargs={"pk": self.channel.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["included_licenses"], [1, 2]) + self.assertEqual(data["non_distributable_licenses_included"], [1]) + def test_get_version_detail_excludes_special_permissions_included(self): """Test that special_permissions_included is not in the response.""" special_license = AuditedSpecialPermissionsLicense.objects.create( @@ -1587,6 +1602,26 @@ def test_get_specific_channel_version(self): self.assertEqual(data["channel"], self.channel.id) self.assertEqual(data["version"], channel_version.version) + def test_get_channel_version_dedupes_duplicated_licenses(self): + """Test that duplicated license ids stored on the ChannelVersion (e.g. from a + backfill bug) are deduped in both list and detail responses.""" + channel_version = ChannelVersion.objects.filter(channel=self.channel).first() + channel_version.included_licenses = [2, 1, 2, 1] + channel_version.save() + + detail_url = reverse("channelversion-detail", kwargs={"pk": channel_version.id}) + detail_response = self.client.get(detail_url) + self.assertEqual(detail_response.status_code, 200) + self.assertEqual(detail_response.json()["included_licenses"], [1, 2]) + + list_url = reverse("channelversion-list") + f"?channel={self.channel.id}" + list_response = self.client.get(list_url) + self.assertEqual(list_response.status_code, 200) + data = list_response.json() + results = data["results"] if "results" in data else data + version_data = next(r for r in results if r["id"] == channel_version.id) + self.assertEqual(version_data["included_licenses"], [1, 2]) + def test_get_channel_versions_ordering(self): """Test ordering of channel versions.""" url = ( diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 3175e8180a..238224226c 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -953,6 +953,17 @@ def get_version_detail(self, request, pk=None) -> Response: if not version_data: return Response({}) + # Older ChannelVersion rows may have been backfilled with duplicate + # license IDs - dedupe defensively. + if version_data.get("included_licenses") is not None: + version_data["included_licenses"] = sorted( + set(version_data["included_licenses"]) + ) + if version_data.get("non_distributable_licenses_included") is not None: + version_data["non_distributable_licenses_included"] = sorted( + set(version_data["non_distributable_licenses_included"]) + ) + return Response(version_data) @action( @@ -1073,6 +1084,12 @@ class ChannelVersionViewSet(ReadOnlyValuesViewset): ordering_fields = ["version"] ordering = "-version" + # Older ChannelVersion rows may have been backfilled with duplicate + # license IDs - dedupe defensively. + field_map = { + "included_licenses": lambda item: sorted(set(item["included_licenses"] or [])) + } + values = ( "id", "channel", From 9406e653e13566db4f0e378d4d5b601564931df9 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 31 Jul 2026 15:16:19 -0500 Subject: [PATCH 3/9] fix: preserve channel status filter when navigating back to channels table Navigating back to the admin Channels table (e.g. after opening a channel and using the browser back button) reset the status filter even though it was correctly restored from the URL. An immediate watcher unconditionally overwrote it to the first available option on every mount instead of only defaulting when the filter is actually unset. Since the status filter is derived from the current channel type's options, a status no longer valid for a new type already reads back as unset, so a single "is it set" check covers both the type-change reset and back-navigation preservation. Co-Authored-By: Claude Sonnet 5 --- .../pages/Channels/ChannelTable.vue | 9 +++++-- .../Channels/__tests__/channelTable.spec.js | 27 ++++++++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue index 2dfdec7fae..c5ae96e7d5 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelTable.vue @@ -304,11 +304,16 @@ fetchQueryParams: keywordSearchFetchQueryParams, } = useKeywordSearch(); + // channelStatusFilter is derived from the current channelType's options, so an + // existing status that's no longer valid for the new type already reads back as + // unset - only default it to the first option in that case. watch( channelTypeFilter, () => { - const options = channelStatusOptions.value; - channelStatusFilter.value = options.length ? options[0].value : null; + if (!channelStatusFilter.value) { + const options = channelStatusOptions.value; + channelStatusFilter.value = options.length ? options[0].value : null; + } }, { immediate: true }, ); diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelTable.spec.js b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelTable.spec.js index 07c08be509..ec50bc8925 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelTable.spec.js +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/__tests__/channelTable.spec.js @@ -11,8 +11,8 @@ localVue.use(router); const channelList = ['test', 'channel', 'table']; -function makeWrapper(store) { - router.replace({ name: RouteNames.CHANNELS }); +function makeWrapper(store, query = {}) { + router.replace({ name: RouteNames.CHANNELS, query }); return mount(ChannelTable, { router, @@ -71,13 +71,32 @@ describe('channelTable', () => { expect(router.currentRoute.query.keywords).toBe('keyword test'); }); - it('changing channel type filter should reset channel status filter', async () => { + it('changing channel type filter should reset channel status filter when it is no longer valid', async () => { + wrapper.vm.channelTypeFilter = ChannelTypeFilter.COMMUNITY_LIBRARY; + wrapper.vm.channelStatusFilter = 'needsReview'; + await wrapper.vm.$nextTick(); + // Kolibri library channels have no "needs review" status + wrapper.vm.channelTypeFilter = ChannelTypeFilter.KOLIBRI_LIBRARY; + await wrapper.vm.$nextTick(); + expect(wrapper.vm.channelStatusFilter).toBe('live'); + }); + it('changing channel type filter should keep the channel status filter when it is still valid', async () => { wrapper.vm.channelTypeFilter = ChannelTypeFilter.COMMUNITY_LIBRARY; wrapper.vm.channelStatusFilter = 'published'; await wrapper.vm.$nextTick(); + // "published" is a valid status for unlisted channels too wrapper.vm.channelTypeFilter = ChannelTypeFilter.UNLISTED; await wrapper.vm.$nextTick(); - expect(wrapper.vm.channelStatusFilter).toBe('live'); + expect(wrapper.vm.channelStatusFilter).toBe('published'); + }); + it('should preserve a valid channel status filter already present in the URL on mount', () => { + // Simulates navigating back to this page with filters still in the URL + // (e.g. after opening a channel and hitting the browser back button). + const backNavWrapper = makeWrapper(store, { + channelType: ChannelTypeFilter.COMMUNITY_LIBRARY, + channelStatus: 'needsReview', + }); + expect(backNavWrapper.vm.channelStatusFilter).toBe('needsReview'); }); }); describe('selection', () => { From 7295cc799511c1eef1e5a84ec84029322741f937 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 31 Jul 2026 15:25:05 -0500 Subject: [PATCH 4/9] fix: remove fixed width from community library status button The fixed width clipped longer status labels. Let it size to content. Co-Authored-By: Claude Sonnet 5 --- .../administration/components/CommunityLibraryStatusButton.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/administration/components/CommunityLibraryStatusButton.vue b/contentcuration/contentcuration/frontend/administration/components/CommunityLibraryStatusButton.vue index 46006c211b..ab95f69dcb 100644 --- a/contentcuration/contentcuration/frontend/administration/components/CommunityLibraryStatusButton.vue +++ b/contentcuration/contentcuration/frontend/administration/components/CommunityLibraryStatusButton.vue @@ -105,7 +105,6 @@ .community-library-status-button { @extend %md-standard-func; - width: 9em; padding: 4px; color: v-bind('labelColor'); background-color: v-bind('color'); From 3fab968b80e53cc2b5cfd65faf58ba2b3f03bf60 Mon Sep 17 00:00:00 2001 From: Marcella Maki Date: Tue, 4 Aug 2026 17:42:38 -0400 Subject: [PATCH 5/9] Simplify strings and update the place in the workflow that the mail is initiated --- contentcuration/contentcuration/models.py | 63 +++++++++---------- .../submission_resolved_email.html | 16 +++-- .../test_community_library_submission.py | 54 +++++++++++++++- contentcuration/contentcuration/utils/i18n.py | 14 +++++ .../viewsets/community_library_submission.py | 13 +++- 5 files changed, 119 insertions(+), 41 deletions(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index bcf5b9bdc2..a1918cdd85 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -51,6 +51,7 @@ from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone +from django.utils import translation from django.utils.translation import gettext as _ from django_cte import CTEManager from django_cte import CTEQuerySet @@ -88,7 +89,7 @@ from contentcuration.db.models.manager import CustomContentNodeTreeManager from contentcuration.db.models.manager import CustomManager from contentcuration.utils.cache import delete_public_channel_cache_keys -from contentcuration.utils.messages import get_messages +from contentcuration.utils.i18n import closest_supported_locale from contentcuration.utils.parser import load_json_string from contentcuration.utils.urls import canonical_url from contentcuration.viewsets.sync.constants import ALL_CHANGES @@ -3061,40 +3062,36 @@ def send_resolution_email(self): """ is_approved = self.status == community_library_submission.STATUS_APPROVED - community_strings = get_messages().get("CommunityChannelsStrings", {}) - status_message = ( - community_strings["approvedStatus"] - if is_approved - else community_strings["flaggedStatus"] - ) - subject_text = "{}: {}".format( - community_strings["communityLibrarySubmissionLabel"], status_message - ) + channel_language = self.channel.language + locale_code = ( + closest_supported_locale(channel_language.lang_code) + if channel_language + else None + ) or settings.LANGUAGE_CODE + with translation.override(locale_code): + if is_approved: + subject_text = _("Your Community Library submission has been approved") + else: + subject_text = _("Your Community Library submission needs changes") - subject = render_to_string( - "registration/custom_email_subject.txt", - {"subject": subject_text}, - ) - subject = "".join(subject.splitlines()) + subject = render_to_string( + "registration/custom_email_subject.txt", + {"subject": subject_text}, + ) + subject = "".join(subject.splitlines()) - message = render_to_string( - "community_library/submission_resolved_email.html", - { - "name": self.author.get_full_name(), - "channel": self.channel, - "channel_url": canonical_url( - reverse("channel", kwargs={"channel_id": self.channel.pk}) - ), - "approved": is_approved, - "status_message": ( - community_strings["availableStatus"] - if is_approved - else community_strings["needsChangesPrimaryInfo"] - ), - "feedback_notes_label": community_strings["feedbackNotesLabel"], - "feedback_notes": self.feedback_notes, - }, - ) + message = render_to_string( + "community_library/submission_resolved_email.html", + { + "name": self.author.get_full_name(), + "channel": self.channel, + "channel_url": canonical_url( + reverse("channel", kwargs={"channel_id": self.channel.pk}) + ), + "approved": is_approved, + "feedback_notes": self.feedback_notes, + }, + ) self.author.email_user( subject, message, settings.DEFAULT_FROM_EMAIL, html_message=message diff --git a/contentcuration/contentcuration/templates/community_library/submission_resolved_email.html b/contentcuration/contentcuration/templates/community_library/submission_resolved_email.html index 0c187e947a..f5aec4f346 100644 --- a/contentcuration/contentcuration/templates/community_library/submission_resolved_email.html +++ b/contentcuration/contentcuration/templates/community_library/submission_resolved_email.html @@ -1,20 +1,25 @@ {% load i18n %} - +{% get_current_language as LANGUAGE_CODE %} +{% get_current_language_bidi as LANGUAGE_BIDI %} + - {% autoescape off %}

{% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %}

-

{% blocktrans with channel_name=channel.name %}{{ channel_name }}{% endblocktrans %} ({{ channel_url }})

+

{{ channel.name }} ({{ channel_url }})

-

{{ status_message }}

+ {% if approved %} +

{% translate "Your submission has been approved and will be added to the Community Library soon." %}

+ {% else %} +

{% translate "Your submission needs changes. Please review the notes below and resubmit after all feedback has been addressed." %}

+ {% endif %} {% if feedback_notes %} -

{{ feedback_notes_label }}: {{ feedback_notes }}

+

{% translate "Notes from the reviewer" %}: {{ feedback_notes }}

{% endif %}

@@ -22,6 +27,5 @@
{% translate "The Learning Equality Team" %}

- {% endautoescape %} diff --git a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py index 9c960149ad..149cd3ade6 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py +++ b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py @@ -17,6 +17,7 @@ from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.helpers import reverse_with_query +from contentcuration.utils.urls import canonical_url from contentcuration.viewsets.sync.constants import ADDED_TO_COMMUNITY_LIBRARY @@ -736,8 +737,53 @@ def test_resolve_submission__accept_correct(self, apply_task_mock): sent_email = mail.outbox[0] self.assertEqual(sent_email.to, [self.submission.author.email]) self.assertIn("approved", sent_email.subject.lower()) - self.assertIn("available in community library", sent_email.body.lower()) + self.assertIn("approved", sent_email.body.lower()) self.assertIn(self.submission.channel.name, sent_email.body) + self.assertIn( + canonical_url( + reverse("channel", kwargs={"channel_id": self.submission.channel.pk}) + ), + sent_email.body, + ) + + @mock.patch( + "contentcuration.viewsets.community_library_submission.apply_channel_changes_task" + ) + @mock.patch( + "contentcuration.models.CommunityLibrarySubmission.send_resolution_email", + side_effect=Exception("SMTP is down"), + ) + def test_resolve_submission__accept_correct_when_email_fails( + self, send_email_mock, apply_task_mock + ): + """A failure to notify the author shouldn't undo or fail the resolution.""" + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + self.resolve_approve_metadata, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + + resolved_submission = CommunityLibrarySubmission.objects.get( + id=self.submission.id + ) + self.assertEqual( + resolved_submission.status, + community_library_submission_constants.STATUS_APPROVED, + ) + Change.objects.get( + channel=self.submission.channel, + change_type=ADDED_TO_COMMUNITY_LIBRARY, + ) + apply_task_mock.fetch_or_enqueue.assert_called_once_with( + self.admin_user, + channel_id=self.submission.channel.id, + ) + self.assertEqual(len(mail.outbox), 0) @mock.patch( "contentcuration.viewsets.community_library_submission.apply_channel_changes_task" @@ -784,6 +830,12 @@ def test_resolve_submission__reject_correct(self, apply_task_mock): self.assertIn("needs changes", sent_email.subject.lower()) self.assertIn("needs changes", sent_email.body.lower()) self.assertIn(self.submission.channel.name, sent_email.body) + self.assertIn( + canonical_url( + reverse("channel", kwargs={"channel_id": self.submission.channel.pk}) + ), + sent_email.body, + ) self.assertIn(self.feedback_notes, sent_email.body) def test_resolve_submission__reject_missing_resolution_reason(self): diff --git a/contentcuration/contentcuration/utils/i18n.py b/contentcuration/contentcuration/utils/i18n.py index dd60689ce6..cde1b00d72 100644 --- a/contentcuration/contentcuration/utils/i18n.py +++ b/contentcuration/contentcuration/utils/i18n.py @@ -41,6 +41,20 @@ def _get_language_info(): LANGUAGE_INFO = _get_language_info() +def closest_supported_locale(lang_code): + """ + Given a content language's primary code (e.g. "es", "fr"), return the + Studio UI locale in SUPPORTED_LANGUAGES that matches it, ignoring region, + or None if Studio has no UI translation for that language. + """ + if not lang_code: + return None + for supported in SUPPORTED_LANGUAGES: + if supported.split("-")[0] == lang_code: + return supported + return None + + def language_globals(): language_code = get_language() lang_dir = "rtl" if get_language_bidi() else "ltr" diff --git a/contentcuration/contentcuration/viewsets/community_library_submission.py b/contentcuration/contentcuration/viewsets/community_library_submission.py index ff9723f92f..167798d973 100644 --- a/contentcuration/contentcuration/viewsets/community_library_submission.py +++ b/contentcuration/contentcuration/viewsets/community_library_submission.py @@ -1,3 +1,5 @@ +import logging + from django.db.models import OuterRef from django.db.models import Subquery from django_filters import BaseInFilter @@ -36,6 +38,8 @@ ) from contentcuration.viewsets.user import IsAdminUser +logger = logging.getLogger(__name__) + class ChoiceInFilter(BaseInFilter, ChoiceFilter): """ @@ -358,6 +362,13 @@ def resolve(self, request, pk=None): published_version.id ) - submission.send_resolution_email() + try: + submission.send_resolution_email() + except Exception: + # The resolution itself has already been committed; a failure to + # notify the author shouldn't turn that into a 500 response. + logger.exception( + "Failed to send resolution email for submission %s", submission.pk + ) return Response(self.serialize_object()) From 3dc898cbb6f87072c7d406e57af45a6bb7870031 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 18 Aug 2026 12:43:13 -0700 Subject: [PATCH 6/9] 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 7/9] 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" />
+