Skip to content

Commit 7069f36

Browse files
authored
News moderation email magic links (#1600)
1 parent dc67da8 commit 7069f36

11 files changed

+397
-43
lines changed

Diff for: config/settings.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@
490490

491491
# Deployed email configuration
492492
if LOCAL_DEVELOPMENT:
493-
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
493+
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
494494
else:
495495
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"
496496
ANYMAIL = {

Diff for: config/urls.py

+12
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@
4646
EntryDeleteView,
4747
EntryDetailView,
4848
EntryListView,
49+
EntryModerationDetailView,
4950
EntryModerationListView,
51+
EntryModerationMagicApproveView,
5052
EntryUpdateView,
5153
LinkCreateView,
5254
LinkListView,
@@ -229,6 +231,16 @@
229231
path("news/add/poll/", PollCreateView.as_view(), name="news-poll-create"),
230232
path("news/add/video/", VideoCreateView.as_view(), name="news-video-create"),
231233
path("news/moderate/", EntryModerationListView.as_view(), name="news-moderate"),
234+
path(
235+
"news/moderate/<slug:slug>/",
236+
EntryModerationDetailView.as_view(),
237+
name="news-moderate-detail",
238+
),
239+
path(
240+
"news/moderate/magic/<str:token>/",
241+
EntryModerationMagicApproveView.as_view(),
242+
name="news-magic-approve",
243+
),
232244
path("news/entry/<slug:slug>/", EntryDetailView.as_view(), name="news-detail"),
233245
path(
234246
"news/entry/<slug:slug>/approve/",

Diff for: news/constants.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
NEWS_APPROVAL_SALT = "news-approval"
2+
MAGIC_LINK_EXPIRATION = 3600 * 24 # 24h

Diff for: news/notifications.py

+47-34
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
from django.conf import settings
12
from django.contrib.auth import get_user_model
2-
from django.core.mail import EmailMessage, get_connection, send_mail
3+
from django.core.mail import (
4+
EmailMessage,
5+
get_connection,
6+
send_mail,
7+
EmailMultiAlternatives,
8+
)
39
from django.template import Template, Context
10+
from django.template.loader import render_to_string
411
from django.urls import reverse
512
from django.utils.safestring import mark_safe
13+
from itsdangerous.url_safe import URLSafeTimedSerializer
614

715
from .acl import moderators
16+
from .constants import NEWS_APPROVAL_SALT, MAGIC_LINK_EXPIRATION
817

918
User = get_user_model()
1019

@@ -37,47 +46,51 @@ def send_email_news_approved(request, entry):
3746
)
3847

3948

49+
def generate_magic_approval_link(entry_slug: str, moderator_id: int):
50+
"""Generate a magic link token for approving a news entry."""
51+
serializer = URLSafeTimedSerializer(settings.SECRET_KEY)
52+
token = serializer.dumps(
53+
{"entry_slug": entry_slug, "moderator_id": moderator_id},
54+
salt=NEWS_APPROVAL_SALT,
55+
)
56+
url = reverse("news-magic-approve", args=[token])
57+
return url
58+
59+
4060
def send_email_news_needs_moderation(request, entry):
41-
recipient_list = sorted(
42-
u.email
61+
recipient_list = [
62+
u
4363
for u in moderators().select_related("preferences").only("email")
4464
if entry.tag in u.preferences.allow_notification_others_news_needs_moderation
45-
)
65+
]
4666
if not recipient_list:
4767
return False
4868

49-
template = Template(
50-
"Hello! You are receiving this email because you are a Boost news moderator.\n"
51-
"The user {{ user.get_display_name|default:user.email }} has submitted a "
52-
"new {{ newstype }} that requires moderation:\n\n"
53-
"{{ title }}\n\n"
54-
"You can view, approve or delete this item at: {{ detail_url }}.\n\n"
55-
"The complete list of news pending moderation can be found at: {{ url }}\n\n"
56-
"Thank you, the Boost moderator team."
57-
)
58-
59-
body = template.render(
60-
Context(
61-
{
62-
"entry": entry,
63-
"user": entry.author,
64-
"newstype": entry.tag,
65-
"detail_url": mark_safe(
66-
request.build_absolute_uri(entry.get_absolute_url())
67-
),
68-
"url": mark_safe(request.build_absolute_uri(reverse("news-moderate"))),
69-
"title": mark_safe(entry.title),
70-
}
71-
)
72-
)
69+
context = {
70+
"entry": entry,
71+
"detail_url": request.build_absolute_uri(entry.get_absolute_url()),
72+
"moderate_url": request.build_absolute_uri(reverse("news-moderate")),
73+
"expiration_hours": int(MAGIC_LINK_EXPIRATION / 3600),
74+
}
7375

7476
subject = "Boost.org: News entry needs moderation"
75-
return send_mail(
76-
subject=subject,
77-
message=body,
78-
from_email=None,
79-
recipient_list=recipient_list,
80-
)
77+
from_address = settings.DEFAULT_FROM_EMAIL
78+
# Send each recipient their own email
79+
messages = []
80+
for moderator in recipient_list:
81+
magic_link_url = generate_magic_approval_link(
82+
entry_slug=entry.slug, moderator_id=moderator.id
83+
)
84+
context["approval_magic_link"] = request.build_absolute_uri(magic_link_url)
85+
text_body = render_to_string("news/emails/needs_moderation.txt", context)
86+
html_body = render_to_string("news/emails/needs_moderation.html", context)
87+
msg = EmailMultiAlternatives(
88+
subject, text_body, from_address, [moderator.email]
89+
)
90+
msg.attach_alternative(html_body, "text/html")
91+
messages.append(msg)
92+
get_connection().send_messages(messages)
93+
return len(messages)
8194

8295

8396
def send_email_news_posted(request, entry):

Diff for: news/tests/test_notifications.py

+35-4
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
from datetime import date
22

33
import pytest
4+
from django.conf import settings
45
from django.core import mail
56
from django.utils.html import escape
67
from django.urls import reverse
8+
from itsdangerous import URLSafeTimedSerializer
79

10+
from ..constants import NEWS_APPROVAL_SALT
811
from ..models import NEWS_MODELS
912
from ..notifications import (
1013
send_email_news_approved,
1114
send_email_news_needs_moderation,
1215
send_email_news_posted,
16+
generate_magic_approval_link,
1317
)
1418
from users.models import Preferences
1519

@@ -97,8 +101,8 @@ def test_send_email_news_needs_moderation(
97101
with tp.assertNumQueriesLessThan(2, verbose=True):
98102
result = send_email_news_needs_moderation(request, entry)
99103

100-
assert result == 1
101-
assert len(mail.outbox) == 1
104+
assert result == 4
105+
assert len(mail.outbox) == 4
102106
msg = mail.outbox[0]
103107
assert "news entry needs moderation" in msg.subject.lower()
104108
assert entry.title in msg.body
@@ -107,9 +111,36 @@ def test_send_email_news_needs_moderation(
107111
assert entry.author.email in msg.body
108112
assert request.build_absolute_uri(entry.get_absolute_url()) in msg.body
109113
assert request.build_absolute_uri(reverse("news-moderate")) in msg.body
110-
assert msg.recipients() == sorted(
111-
[other_moderator.email, moderator_user.email, superuser.email, forth.email]
114+
recipients = []
115+
for msg in mail.outbox:
116+
recipients.extend(msg.recipients())
117+
assert set(recipients) == {
118+
other_moderator.email,
119+
moderator_user.email,
120+
superuser.email,
121+
forth.email,
122+
}
123+
124+
125+
def test_generate_magic_approval_link(make_entry, make_user):
126+
entry = make_entry()
127+
moderator = make_user(groups={"moderator": ["news.*"]}, email="[email protected]")
128+
url = generate_magic_approval_link(entry.slug, moderator.id)
129+
130+
dummy_token = "dummy-token"
131+
expected_base_url = (
132+
reverse("news-magic-approve", kwargs={"token": dummy_token})
133+
.replace(dummy_token, "")
134+
.rstrip("/")
112135
)
136+
assert url.startswith(expected_base_url)
137+
138+
token = url.split(expected_base_url)[-1].strip("/")
139+
serializer = URLSafeTimedSerializer(settings.SECRET_KEY)
140+
data = serializer.loads(token, salt=NEWS_APPROVAL_SALT)
141+
142+
assert data["entry_slug"] == entry.slug
143+
assert data["moderator_id"] == moderator.id
113144

114145

115146
@pytest.mark.parametrize("model_class", NEWS_MODELS)

Diff for: news/views.py

+45-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from datetime import timedelta
22

3+
from django.conf import settings
34
from django.contrib import messages
5+
from django.contrib.auth import get_user_model
46
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
57
from django.contrib.humanize.templatetags import humanize
68
from django.contrib.messages.views import SuccessMessageMixin
7-
from django.http import Http404, HttpResponseRedirect
8-
from django.shortcuts import redirect
9+
from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden
10+
from django.shortcuts import redirect, get_object_or_404
911
from django.template.defaultfilters import date as datefilter
1012
from django.urls import reverse_lazy
1113
from django.utils.http import url_has_allowed_host_and_scheme
@@ -21,8 +23,10 @@
2123
View,
2224
)
2325
from django.views.generic.detail import SingleObjectMixin
26+
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData
2427

2528
from .acl import can_approve
29+
from .constants import NEWS_APPROVAL_SALT, MAGIC_LINK_EXPIRATION
2630
from .forms import BlogPostForm, EntryForm, LinkForm, NewsForm, PollForm, VideoForm
2731
from .models import BlogPost, Entry, Link, News, Poll, Video
2832
from .notifications import (
@@ -31,6 +35,8 @@
3135
send_email_news_posted,
3236
)
3337

38+
User = get_user_model()
39+
3440

3541
def get_published_or_none(sibling_getter):
3642
"""Helper method to get next/prev published sibling of a given entry."""
@@ -129,7 +135,8 @@ class EntryDetailView(DetailView):
129135
template_name = "news/detail.html"
130136

131137
def get_object(self, *args, **kwargs):
132-
# Published news are available to anyone, otherwise to authors only
138+
# Published news are available to anyone,
139+
# otherwise to authors and moderators only
133140
result = super().get_object(*args, **kwargs)
134141
if not result.can_view(self.request.user):
135142
raise Http404()
@@ -148,6 +155,41 @@ def get_context_data(self, **kwargs):
148155
return context
149156

150157

158+
class EntryModerationDetailView(LoginRequiredMixin, EntryDetailView): ...
159+
160+
161+
class EntryModerationMagicApproveView(View):
162+
"""Approve a news entry without requiring moderator login."""
163+
164+
def get(self, request, token, *args, **kwargs):
165+
serializer = URLSafeTimedSerializer(settings.SECRET_KEY)
166+
try:
167+
data = serializer.loads(
168+
token, salt=NEWS_APPROVAL_SALT, max_age=MAGIC_LINK_EXPIRATION
169+
)
170+
entry_slug = data["entry_slug"]
171+
moderator_id = data["moderator_id"]
172+
moderator = User.objects.get(id=moderator_id)
173+
except SignatureExpired:
174+
message = _("This link has expired.")
175+
if not request.user.is_authenticated():
176+
message += _(" Please login to continue.")
177+
messages.warning(request, message)
178+
return redirect(reverse_lazy("news-moderate"), permanent=True)
179+
except (BadData, User.DoesNotExist):
180+
return HttpResponseForbidden("Invalid magic link.")
181+
182+
entry = get_object_or_404(Entry, slug=entry_slug)
183+
184+
try:
185+
entry.approve(moderator)
186+
messages.success(request, _("This entry has been approved."))
187+
except Entry.AlreadyApprovedError:
188+
messages.warning(request, _("This entry has already been approved."))
189+
190+
return redirect(entry, permanent=True)
191+
192+
151193
class EntryCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
152194
model = None
153195
form_class = None

Diff for: requirements.in

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ psycogreen
2020
gevent==24.2.1
2121
gunicorn
2222
interrogate
23+
itsdangerous
2324
psycopg2-binary
2425
whitenoise
2526
django-click

Diff for: requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ interrogate==1.7.0
188188
# via -r ./requirements.in
189189
ipython==8.28.0
190190
# via -r ./requirements.in
191+
itsdangerous==2.2.0
192+
# via -r ./requirements.in
191193
jedi==0.19.1
192194
# via ipython
193195
jmespath==1.0.1

0 commit comments

Comments
 (0)