mirror of
https://github.com/boostorg/website-v2.git
synced 2026-01-19 04:42:17 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
63
news/notifications.py
Normal 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,
|
||||
)
|
||||
51
news/tests/test_notifications.py
Normal file
51
news/tests/test_notifications.py
Normal 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]
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user