diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py
index 1f0c1ec8c5..a1918cdd85 100644
--- a/contentcuration/contentcuration/models.py
+++ b/contentcuration/contentcuration/models.py
@@ -48,7 +48,10 @@
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 import translation
from django.utils.translation import gettext as _
from django_cte import CTEManager
from django_cte import CTEQuerySet
@@ -86,7 +89,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.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
from contentcuration.viewsets.sync.constants import ALL_TABLES
from contentcuration.viewsets.sync.constants import PUBLISHABLE_CHANGE_TABLES
@@ -3049,6 +3054,49 @@ 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
+
+ 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())
+
+ 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
+ )
+
@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..f5aec4f346
--- /dev/null
+++ b/contentcuration/contentcuration/templates/community_library/submission_resolved_email.html
@@ -0,0 +1,31 @@
+
+{% load i18n %}
+{% get_current_language as LANGUAGE_CODE %}
+{% get_current_language_bidi as LANGUAGE_BIDI %}
+
+
+
+
+
+
+ {% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %}
+
+ {{ channel.name }} ({{ channel_url }})
+
+ {% 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 %}
+ {% translate "Notes from the reviewer" %}: {{ feedback_notes }}
+ {% endif %}
+
+
+ {% translate "Thanks for using Kolibri Studio!" %}
+
+ {% translate "The Learning Equality Team" %}
+
+
+
diff --git a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py
index 4cd51fb2a1..149cd3ade6 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 (
@@ -16,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
@@ -731,6 +733,58 @@ 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("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"
)
@@ -770,6 +824,20 @@ 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(
+ 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):
self.client.force_authenticate(user=self.admin_user)
metadata = self.resolve_reject_metadata.copy()
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 33fc6f9a94..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,4 +362,13 @@ def resolve(self, request, pk=None):
published_version.id
)
+ 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())