From d0b294be0ee3ef447b072adf64819173d06fa172 Mon Sep 17 00:00:00 2001 From: Marcella Maki Date: Tue, 21 Jul 2026 17:01:11 -0400 Subject: [PATCH 1/2] 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 3fab968b80e53cc2b5cfd65faf58ba2b3f03bf60 Mon Sep 17 00:00:00 2001 From: Marcella Maki Date: Tue, 4 Aug 2026 17:42:38 -0400 Subject: [PATCH 2/2] 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())