From 96aabf4412ce0440990f9e948dbb5f98ef81a8d5 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Thu, 1 Jun 2023 20:59:38 -0300 Subject: [PATCH] Send email notifications for news approcal and moderation (Part of #343) This work includes the adding of helpers to send emails (new notifications module). --- config/settings.py | 7 ++++ docker-compose.yml | 11 ++++- news/notifications.py | 63 +++++++++++++++++++++++++++++ news/tests/test_notifications.py | 51 +++++++++++++++++++++++ news/tests/test_views.py | 69 +++++++++++++++++++++++++++++--- news/views.py | 11 ++++- requirements.in | 1 + requirements.txt | 6 +++ 8 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 news/notifications.py create mode 100644 news/tests/test_notifications.py diff --git a/config/settings.py b/config/settings.py index 956924a1..84c17895 100755 --- a/config/settings.py +++ b/config/settings.py @@ -58,6 +58,7 @@ INSTALLED_APPS = [ # Third-party apps INSTALLED_APPS += [ + "anymail", "rest_framework", "django_extensions", "health_check", @@ -417,3 +418,9 @@ NEWS_MODERATION_ALLOWLIST = [ # Add either a user's email address or a User instance PK. Mixing emails # with PKs is safe since users.User's PKs are integers. ] + +# EMAIL SETTINGS -- THESE NEED ADJUSTMENT WHEN DECIDED WHICH ESP WILL BE USED +EMAIL_HOST = "maildev" +EMAIL_PORT = 1025 +DEFAULT_FROM_EMAIL = "news@boost.org" +SERVER_EMAIL = "errors@boost.org" diff --git a/docker-compose.yml b/docker-compose.yml index 577f8442..828a7b60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3.3" +version: "3.7" services: @@ -126,6 +126,15 @@ services: - .:/code stop_signal: SIGKILL + maildev: + image: maildev/maildev + init: true + ports: + - "1025:1025" + - "1080:1080" + networks: + - backend + stop_signal: SIGKILL networks: backend: diff --git a/news/notifications.py b/news/notifications.py new file mode 100644 index 00000000..5b49dcbe --- /dev/null +++ b/news/notifications.py @@ -0,0 +1,63 @@ +from django.contrib.auth import get_user_model +from django.core.mail import send_mail +from django.template import Template, Context +from django.urls import reverse + +from .acl import moderators + +User = get_user_model() + + +def send_email_after_approval(request, entry): + template = Template( + 'Congratulations! The news entry "{{ entry.title }}" that you submitted on ' + '{{ entry.created_at|date:"M jS, Y" }} was approved.\n' + "You can view this news at {{ url }}.\n\n" + "Thank you, the Boost moderator team." + ) + body = template.render( + Context( + { + "entry": entry, + "url": request.build_absolute_uri(entry.get_absolute_url()), + } + ) + ) + subject = "Boost.org: News entry approved" + return send_mail( + subject=subject, + message=body, + from_email=None, + recipient_list=[entry.author.email], + ) + + +def send_email_news_needs_moderation(request, entry): + template = Template( + "Hello! You are receiving this email because you are a Boost news moderator.\n" + "The user {{ user.get_display_name|default:user.email }} has submitted a " + "new {{ newstype }} that requires moderation:\n\n" + "{{ entry.title }}\n\n" + "You can view, approve or delete this item at: {{ detail_url }}.\n\n" + "The complete list of news pending moderation can be found at: {{ url }}\n\n" + "Thank you, the Boost moderator team." + ) + body = template.render( + Context( + { + "entry": entry, + "user": entry.author, + "newstype": entry.__class__.__name__.lower(), + "detail_url": request.build_absolute_uri(entry.get_absolute_url()), + "url": request.build_absolute_uri(reverse("news-moderate")), + } + ) + ) + subject = "Boost.org: News entry needs moderation" + recipient_list = sorted(u.email for u in moderators().only("email")) + return send_mail( + subject=subject, + message=body, + from_email=None, + recipient_list=recipient_list, + ) diff --git a/news/tests/test_notifications.py b/news/tests/test_notifications.py new file mode 100644 index 00000000..66297557 --- /dev/null +++ b/news/tests/test_notifications.py @@ -0,0 +1,51 @@ +from datetime import date + +from django.core import mail +from django.urls import reverse + +from ..notifications import send_email_after_approval, send_email_news_needs_moderation + + +def test_send_email_after_approval(rf, tp, make_entry): + entry = make_entry(approved=True, created_at=date(2023, 5, 31)) + request = rf.get("") + + result = send_email_after_approval(request, entry) + + assert result == 1 + assert len(mail.outbox) == 1 + msg = mail.outbox[0] + assert "news entry approved" in msg.subject.lower() + assert entry.title in msg.body + assert "May 31st, 2023" in msg.body + assert request.build_absolute_uri(entry.get_absolute_url()) in msg.body + assert msg.recipients() == [entry.author.email] + + +def test_send_email_news_needs_moderation( + rf, + tp, + make_entry, + make_user, + moderator_user, + superuser, +): + other_moderator = make_user(groups={"moderator": ["news.*"]}) + entry = make_entry(approved=True) + request = rf.get("") + + with tp.assertNumQueriesLessThan(2, verbose=True): + result = send_email_news_needs_moderation(request, entry) + + assert result == 1 + assert len(mail.outbox) == 1 + msg = mail.outbox[0] + assert "news entry needs moderation" in msg.subject.lower() + assert entry.title in msg.body + assert entry.author.get_display_name in msg.body + assert entry.author.email in msg.body + assert request.build_absolute_uri(entry.get_absolute_url()) in msg.body + assert request.build_absolute_uri(reverse("news-moderate")) in msg.body + assert msg.recipients() == sorted( + [other_moderator.email, moderator_user.email, superuser.email] + ) diff --git a/news/tests/test_views.py b/news/tests/test_views.py index 992c836d..1c5304dc 100644 --- a/news/tests/test_views.py +++ b/news/tests/test_views.py @@ -4,6 +4,7 @@ import uuid from io import BytesIO import pytest +from django.core import mail from django.utils.text import slugify from django.utils.timezone import now from model_bakery import baker @@ -11,6 +12,7 @@ from model_bakery import baker from ..forms import BlogPostForm, EntryForm, LinkForm, PollForm, VideoForm from ..models import BlogPost, Entry, Link, Poll, Video +from ..notifications import send_email_after_approval, send_email_news_needs_moderation NEWS_MODELS = [Entry, BlogPost, Link, Poll, Video] @@ -286,10 +288,15 @@ def test_news_create_get(tp, regular_user, url_name, form_class): ) @pytest.mark.parametrize("with_image", [False, True]) def test_news_create_post( - tp, regular_user, url_name, model_class, data_fields, with_image, settings + tp, + url_name, + model_class, + data_fields, + with_image, + regular_user, + moderator_user, + settings, ): - url = tp.reverse(url_name) - data = { k: f"random-value-{k}" if "url" not in k else "http://example.com" for k in data_fields @@ -309,7 +316,7 @@ def test_news_create_post( before = now() with tp.login(regular_user): - response = tp.post(url, data=data, follow=True) + response = tp.post(url_name, data=data, follow=True) after = now() entries = model_class.objects.filter(title=data["title"]) @@ -337,6 +344,20 @@ def test_news_create_post( assert before <= entry.created_at <= after assert before <= entry.modified_at <= after assert entry.publish_at == right_now + # email to moderators was sent + assert len(mail.outbox) == 1 + actual = mail.outbox[0] + # render the same email using the notifications' method to assert equality + assert send_email_news_needs_moderation(response.wsgi_request, entry) == 1 + expected = mail.outbox[1] + assert actual.subject == expected.subject + assert actual.body == expected.body + assert actual.from_email == expected.from_email + assert actual.recipients() == expected.recipients() + assert actual.recipients() == [moderator_user.email] + # success message is shown + messages = [(m.level_tag, m.message) for m in tp.get_context("messages")] + assert messages == [("success", "The news entry was successfully created.")] tp.assertRedirects(response, entry.get_absolute_url()) @@ -375,7 +396,7 @@ def test_news_approve_post(tp, make_entry, regular_user, moderator_user, model_c # moderators users can POST to the view to approve an entry with tp.login(moderator_user): before = now() - response = tp.post(*url_params) + response = tp.post(*url_params, follow=True) after = now() tp.assertRedirects(response, entry.get_absolute_url()) @@ -385,6 +406,44 @@ def test_news_approve_post(tp, make_entry, regular_user, moderator_user, model_c assert entry.moderator == moderator_user assert before <= entry.approved_at <= after assert before <= entry.modified_at <= after + # email was sent + assert len(mail.outbox) == 1 + actual = mail.outbox[0] + # render the same email using the notifications' method to assert equality + assert send_email_after_approval(response.wsgi_request, entry) == 1 + expected = mail.outbox[1] + assert actual.subject == expected.subject + assert actual.body == expected.body + assert actual.from_email == expected.from_email + assert actual.recipients() == expected.recipients() + # success message is shown + messages = [(m.level_tag, m.message) for m in tp.get_context("messages")] + assert messages == [("success", "The entry was successfully approved.")] + + +@pytest.mark.parametrize("model_class", NEWS_MODELS) +def test_news_approve_already_approved(tp, make_entry, moderator_user, model_class): + entry = make_entry(model_class, approved=True) + url_params = ("news-approve", entry.slug) + + with tp.login(moderator_user): + before = now() + response = tp.post(*url_params, follow=True) + now() + + tp.assertRedirects(response, entry.get_absolute_url()) + + entry.refresh_from_db() + # approval information was not changed + assert entry.is_approved is True + assert entry.moderator is not moderator_user + assert entry.approved_at <= before + assert entry.modified_at <= before + # email was not sent + assert mail.outbox == [] + # error message is shown + messages = [(m.level_tag, m.message) for m in tp.get_context("messages")] + assert messages == [("error", "The entry was already approved.")] @pytest.mark.parametrize("model_class", NEWS_MODELS) diff --git a/news/views.py b/news/views.py index 3b74df30..9c9bd64d 100644 --- a/news/views.py +++ b/news/views.py @@ -1,5 +1,6 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.messages.views import SuccessMessageMixin from django.db.models import Case, Value, When from django.http import Http404, HttpResponseRedirect from django.urls import reverse_lazy @@ -18,6 +19,7 @@ from django.views.generic.detail import SingleObjectMixin from .acl import can_approve from .forms import BlogPostForm, EntryForm, LinkForm, PollForm, VideoForm from .models import BlogPost, Entry, Link, Poll, Video +from .notifications import send_email_after_approval, send_email_news_needs_moderation def get_published_or_none(sibling_getter): @@ -107,16 +109,20 @@ class EntryDetailView(DetailView): return context -class EntryCreateView(LoginRequiredMixin, CreateView): +class EntryCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = Entry form_class = EntryForm template_name = "news/form.html" add_label = _("Create News") add_url_name = "news-create" + success_message = _("The news entry was successfully created.") def form_valid(self, form): form.instance.author = self.request.user - return super().form_valid(form) + result = super().form_valid(form) + if not form.instance.is_approved: + send_email_news_needs_moderation(request=self.request, entry=form.instance) + return result def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -171,6 +177,7 @@ class EntryApproveView( messages.error(request, _("The entry was already approved.")) else: messages.success(request, _("The entry was successfully approved.")) + send_email_after_approval(request=request, entry=entry) next_url = request.POST.get("next") if next_url is None or not url_has_allowed_host_and_scheme( diff --git a/requirements.in b/requirements.in index f7b1c0d5..5c816f80 100755 --- a/requirements.in +++ b/requirements.in @@ -2,6 +2,7 @@ Django>=4.0, <=5.0 bumpversion django-admin-env-notice django-allauth==0.53.1 +django-anymail[mailgun] django-db-geventpool django-extensions django-health-check diff --git a/requirements.txt b/requirements.txt index b209fcba..95463363 100755 --- a/requirements.txt +++ b/requirements.txt @@ -64,6 +64,7 @@ coverage[toml]==7.2.6 cryptography==40.0.2 # via # -r ./requirements.in + # django-anymail # pyjwt decorator==5.1.1 # via ipython @@ -78,6 +79,7 @@ django==4.2.1 # -r ./requirements.in # dj-database-url # django-allauth + # django-anymail # django-db-geventpool # django-extensions # django-haystack @@ -93,6 +95,8 @@ django-admin-env-notice==1.0 # via -r ./requirements.in django-allauth==0.53.1 # via -r ./requirements.in +django-anymail[mailgun]==10.0 + # via -r ./requirements.in django-bakery==0.13.2 # via -r ./requirements.in django-cache-url==3.4.4 @@ -268,6 +272,7 @@ requests==2.31.0 # via # -r ./requirements.in # django-allauth + # django-anymail # requests-oauthlib # responses requests-oauthlib==1.3.1 @@ -300,6 +305,7 @@ typing-extensions==4.6.0 urllib3==1.26.16 # via # botocore + # django-anymail # minio # requests # responses