Send email notifications for news approcal and moderation (Part of #343)

This work includes the adding of helpers to send emails (new notifications
module).
This commit is contained in:
Natalia
2023-06-01 20:59:38 -03:00
committed by nessita
parent 008e0de61a
commit 96aabf4412
8 changed files with 211 additions and 8 deletions

View File

@@ -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"

View File

@@ -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:

63
news/notifications.py Normal file
View File

@@ -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,
)

View File

@@ -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]
)

View File

@@ -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)

View File

@@ -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(

View File

@@ -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

View File

@@ -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