Skip to content

Commit 1bc0c9a

Browse files
authored
Merge pull request #6050 from marcellamaki/send-notification-email
Add ability to send a notification email on review of community libra…
2 parents c4f699e + 3fab968 commit 1bc0c9a

5 files changed

Lines changed: 174 additions & 0 deletions

File tree

contentcuration/contentcuration/models.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@
4848
from django.db.models.query_utils import DeferredAttribute
4949
from django.db.models.sql import Query
5050
from django.dispatch import receiver
51+
from django.template.loader import render_to_string
52+
from django.urls import reverse
5153
from django.utils import timezone
54+
from django.utils import translation
5255
from django.utils.translation import gettext as _
5356
from django_cte import CTEManager
5457
from django_cte import CTEQuerySet
@@ -86,7 +89,9 @@
8689
from contentcuration.db.models.manager import CustomContentNodeTreeManager
8790
from contentcuration.db.models.manager import CustomManager
8891
from contentcuration.utils.cache import delete_public_channel_cache_keys
92+
from contentcuration.utils.i18n import closest_supported_locale
8993
from contentcuration.utils.parser import load_json_string
94+
from contentcuration.utils.urls import canonical_url
9095
from contentcuration.viewsets.sync.constants import ALL_CHANGES
9196
from contentcuration.viewsets.sync.constants import ALL_TABLES
9297
from contentcuration.viewsets.sync.constants import PUBLISHABLE_CHANGE_TABLES
@@ -3049,6 +3054,49 @@ def notify_update_to_channel_editors(self, exclude_user_id=None):
30493054

30503055
User.notify_users(editors, date=self.date_updated)
30513056

3057+
def send_resolution_email(self):
3058+
"""
3059+
Send an email to the submission author letting them know their
3060+
Community Library submission has been resolved (approved or
3061+
rejected).
3062+
"""
3063+
is_approved = self.status == community_library_submission.STATUS_APPROVED
3064+
3065+
channel_language = self.channel.language
3066+
locale_code = (
3067+
closest_supported_locale(channel_language.lang_code)
3068+
if channel_language
3069+
else None
3070+
) or settings.LANGUAGE_CODE
3071+
with translation.override(locale_code):
3072+
if is_approved:
3073+
subject_text = _("Your Community Library submission has been approved")
3074+
else:
3075+
subject_text = _("Your Community Library submission needs changes")
3076+
3077+
subject = render_to_string(
3078+
"registration/custom_email_subject.txt",
3079+
{"subject": subject_text},
3080+
)
3081+
subject = "".join(subject.splitlines())
3082+
3083+
message = render_to_string(
3084+
"community_library/submission_resolved_email.html",
3085+
{
3086+
"name": self.author.get_full_name(),
3087+
"channel": self.channel,
3088+
"channel_url": canonical_url(
3089+
reverse("channel", kwargs={"channel_id": self.channel.pk})
3090+
),
3091+
"approved": is_approved,
3092+
"feedback_notes": self.feedback_notes,
3093+
},
3094+
)
3095+
3096+
self.author.email_user(
3097+
subject, message, settings.DEFAULT_FROM_EMAIL, html_message=message
3098+
)
3099+
30523100
@classmethod
30533101
def filter_view_queryset(cls, queryset, user):
30543102
if user.is_anonymous:
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<!DOCTYPE html>
2+
{% load i18n %}
3+
{% get_current_language as LANGUAGE_CODE %}
4+
{% get_current_language_bidi as LANGUAGE_BIDI %}
5+
<html lang="{{ LANGUAGE_CODE }}" dir="{% if LANGUAGE_BIDI %}rtl{% else %}ltr{% endif %}">
6+
<head>
7+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
8+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
9+
</head>
10+
<body>
11+
<p>{% blocktrans with name=name %}Hello {{ name }},{% endblocktrans %}</p>
12+
13+
<p><a href="{{ channel_url }}" target="_blank">{{ channel.name }}</a> ({{ channel_url }})</p>
14+
15+
{% if approved %}
16+
<p>{% translate "Your submission has been approved and will be added to the Community Library soon." %}</p>
17+
{% else %}
18+
<p>{% translate "Your submission needs changes. Please review the notes below and resubmit after all feedback has been addressed." %}</p>
19+
{% endif %}
20+
21+
{% if feedback_notes %}
22+
<p>{% translate "Notes from the reviewer" %}: {{ feedback_notes }}</p>
23+
{% endif %}
24+
25+
<p>
26+
{% translate "Thanks for using Kolibri Studio!" %}
27+
<br>
28+
{% translate "The Learning Equality Team" %}
29+
</p>
30+
</body>
31+
</html>

contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest import mock
33

44
import pytz
5+
from django.core import mail
56
from django.urls import reverse
67

78
from contentcuration.constants import (
@@ -16,6 +17,7 @@
1617
from contentcuration.tests import testdata
1718
from contentcuration.tests.base import StudioAPITestCase
1819
from contentcuration.tests.helpers import reverse_with_query
20+
from contentcuration.utils.urls import canonical_url
1921
from contentcuration.viewsets.sync.constants import ADDED_TO_COMMUNITY_LIBRARY
2022

2123

@@ -731,6 +733,58 @@ def test_resolve_submission__accept_correct(self, apply_task_mock):
731733
channel_id=self.submission.channel.id,
732734
)
733735

736+
self.assertEqual(len(mail.outbox), 1)
737+
sent_email = mail.outbox[0]
738+
self.assertEqual(sent_email.to, [self.submission.author.email])
739+
self.assertIn("approved", sent_email.subject.lower())
740+
self.assertIn("approved", sent_email.body.lower())
741+
self.assertIn(self.submission.channel.name, sent_email.body)
742+
self.assertIn(
743+
canonical_url(
744+
reverse("channel", kwargs={"channel_id": self.submission.channel.pk})
745+
),
746+
sent_email.body,
747+
)
748+
749+
@mock.patch(
750+
"contentcuration.viewsets.community_library_submission.apply_channel_changes_task"
751+
)
752+
@mock.patch(
753+
"contentcuration.models.CommunityLibrarySubmission.send_resolution_email",
754+
side_effect=Exception("SMTP is down"),
755+
)
756+
def test_resolve_submission__accept_correct_when_email_fails(
757+
self, send_email_mock, apply_task_mock
758+
):
759+
"""A failure to notify the author shouldn't undo or fail the resolution."""
760+
self.client.force_authenticate(user=self.admin_user)
761+
response = self.client.post(
762+
reverse(
763+
"admin-community-library-submission-resolve",
764+
args=[self.submission.id],
765+
),
766+
self.resolve_approve_metadata,
767+
format="json",
768+
)
769+
self.assertEqual(response.status_code, 200, response.content)
770+
771+
resolved_submission = CommunityLibrarySubmission.objects.get(
772+
id=self.submission.id
773+
)
774+
self.assertEqual(
775+
resolved_submission.status,
776+
community_library_submission_constants.STATUS_APPROVED,
777+
)
778+
Change.objects.get(
779+
channel=self.submission.channel,
780+
change_type=ADDED_TO_COMMUNITY_LIBRARY,
781+
)
782+
apply_task_mock.fetch_or_enqueue.assert_called_once_with(
783+
self.admin_user,
784+
channel_id=self.submission.channel.id,
785+
)
786+
self.assertEqual(len(mail.outbox), 0)
787+
734788
@mock.patch(
735789
"contentcuration.viewsets.community_library_submission.apply_channel_changes_task"
736790
)
@@ -770,6 +824,20 @@ def test_resolve_submission__reject_correct(self, apply_task_mock):
770824
)
771825
apply_task_mock.fetch_or_enqueue.assert_not_called()
772826

827+
self.assertEqual(len(mail.outbox), 1)
828+
sent_email = mail.outbox[0]
829+
self.assertEqual(sent_email.to, [self.submission.author.email])
830+
self.assertIn("needs changes", sent_email.subject.lower())
831+
self.assertIn("needs changes", sent_email.body.lower())
832+
self.assertIn(self.submission.channel.name, sent_email.body)
833+
self.assertIn(
834+
canonical_url(
835+
reverse("channel", kwargs={"channel_id": self.submission.channel.pk})
836+
),
837+
sent_email.body,
838+
)
839+
self.assertIn(self.feedback_notes, sent_email.body)
840+
773841
def test_resolve_submission__reject_missing_resolution_reason(self):
774842
self.client.force_authenticate(user=self.admin_user)
775843
metadata = self.resolve_reject_metadata.copy()

contentcuration/contentcuration/utils/i18n.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ def _get_language_info():
4141
LANGUAGE_INFO = _get_language_info()
4242

4343

44+
def closest_supported_locale(lang_code):
45+
"""
46+
Given a content language's primary code (e.g. "es", "fr"), return the
47+
Studio UI locale in SUPPORTED_LANGUAGES that matches it, ignoring region,
48+
or None if Studio has no UI translation for that language.
49+
"""
50+
if not lang_code:
51+
return None
52+
for supported in SUPPORTED_LANGUAGES:
53+
if supported.split("-")[0] == lang_code:
54+
return supported
55+
return None
56+
57+
4458
def language_globals():
4559
language_code = get_language()
4660
lang_dir = "rtl" if get_language_bidi() else "ltr"

contentcuration/contentcuration/viewsets/community_library_submission.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
13
from django.db.models import OuterRef
24
from django.db.models import Subquery
35
from django_filters import BaseInFilter
@@ -36,6 +38,8 @@
3638
)
3739
from contentcuration.viewsets.user import IsAdminUser
3840

41+
logger = logging.getLogger(__name__)
42+
3943

4044
class ChoiceInFilter(BaseInFilter, ChoiceFilter):
4145
"""
@@ -358,4 +362,13 @@ def resolve(self, request, pk=None):
358362
published_version.id
359363
)
360364

365+
try:
366+
submission.send_resolution_email()
367+
except Exception:
368+
# The resolution itself has already been committed; a failure to
369+
# notify the author shouldn't turn that into a 500 response.
370+
logger.exception(
371+
"Failed to send resolution email for submission %s", submission.pk
372+
)
373+
361374
return Response(self.serialize_object())

0 commit comments

Comments
 (0)