Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

News moderation email magic links #1600

Merged
merged 9 commits into from
Jan 15, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/settings.py
Original file line number Diff line number Diff line change
@@ -490,7 +490,7 @@

# Deployed email configuration
if LOCAL_DEVELOPMENT:
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
else:
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"
ANYMAIL = {
12 changes: 12 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
@@ -46,7 +46,9 @@
EntryDeleteView,
EntryDetailView,
EntryListView,
EntryModerationDetailView,
EntryModerationListView,
EntryModerationMagicApproveView,
EntryUpdateView,
LinkCreateView,
LinkListView,
@@ -229,6 +231,16 @@
path("news/add/poll/", PollCreateView.as_view(), name="news-poll-create"),
path("news/add/video/", VideoCreateView.as_view(), name="news-video-create"),
path("news/moderate/", EntryModerationListView.as_view(), name="news-moderate"),
path(
"news/moderate/<slug:slug>/",
EntryModerationDetailView.as_view(),
name="news-moderate-detail",
),
path(
"news/moderate/magic/<str:token>/",
EntryModerationMagicApproveView.as_view(),
name="news-magic-approve",
),
path("news/entry/<slug:slug>/", EntryDetailView.as_view(), name="news-detail"),
path(
"news/entry/<slug:slug>/approve/",
2 changes: 2 additions & 0 deletions news/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
NEWS_APPROVAL_SALT = "news-approval"
MAGIC_LINK_EXPIRATION = 3600 * 24 # 24h
81 changes: 47 additions & 34 deletions news/notifications.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.mail import EmailMessage, get_connection, send_mail
from django.core.mail import (
EmailMessage,
get_connection,
send_mail,
EmailMultiAlternatives,
)
from django.template import Template, Context
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.safestring import mark_safe
from itsdangerous.url_safe import URLSafeTimedSerializer

from .acl import moderators
from .constants import NEWS_APPROVAL_SALT, MAGIC_LINK_EXPIRATION

User = get_user_model()

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


def generate_magic_approval_link(entry_slug: str, moderator_id: int):
"""Generate a magic link token for approving a news entry."""
serializer = URLSafeTimedSerializer(settings.SECRET_KEY)
token = serializer.dumps(
{"entry_slug": entry_slug, "moderator_id": moderator_id},
salt=NEWS_APPROVAL_SALT,
)
url = reverse("news-magic-approve", args=[token])
return url


def send_email_news_needs_moderation(request, entry):
recipient_list = sorted(
u.email
recipient_list = [
u
for u in moderators().select_related("preferences").only("email")
if entry.tag in u.preferences.allow_notification_others_news_needs_moderation
)
]
if not recipient_list:
return False

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"
"{{ 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.tag,
"detail_url": mark_safe(
request.build_absolute_uri(entry.get_absolute_url())
),
"url": mark_safe(request.build_absolute_uri(reverse("news-moderate"))),
"title": mark_safe(entry.title),
}
)
)
context = {
"entry": entry,
"detail_url": request.build_absolute_uri(entry.get_absolute_url()),
"moderate_url": request.build_absolute_uri(reverse("news-moderate")),
"expiration_hours": int(MAGIC_LINK_EXPIRATION / 3600),
}

subject = "Boost.org: News entry needs moderation"
return send_mail(
subject=subject,
message=body,
from_email=None,
recipient_list=recipient_list,
)
from_address = settings.DEFAULT_FROM_EMAIL
# Send each recipient their own email
messages = []
for moderator in recipient_list:
magic_link_url = generate_magic_approval_link(
entry_slug=entry.slug, moderator_id=moderator.id
)
context["approval_magic_link"] = request.build_absolute_uri(magic_link_url)
text_body = render_to_string("news/emails/needs_moderation.txt", context)
html_body = render_to_string("news/emails/needs_moderation.html", context)
msg = EmailMultiAlternatives(
subject, text_body, from_address, [moderator.email]
)
msg.attach_alternative(html_body, "text/html")
messages.append(msg)
get_connection().send_messages(messages)
return len(messages)


def send_email_news_posted(request, entry):
39 changes: 35 additions & 4 deletions news/tests/test_notifications.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
from datetime import date

import pytest
from django.conf import settings
from django.core import mail
from django.utils.html import escape
from django.urls import reverse
from itsdangerous import URLSafeTimedSerializer

from ..constants import NEWS_APPROVAL_SALT
from ..models import NEWS_MODELS
from ..notifications import (
send_email_news_approved,
send_email_news_needs_moderation,
send_email_news_posted,
generate_magic_approval_link,
)
from users.models import Preferences

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

assert result == 1
assert len(mail.outbox) == 1
assert result == 4
assert len(mail.outbox) == 4
msg = mail.outbox[0]
assert "news entry needs moderation" in msg.subject.lower()
assert entry.title in msg.body
@@ -107,9 +111,36 @@ def test_send_email_news_needs_moderation(
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, forth.email]
recipients = []
for msg in mail.outbox:
recipients.extend(msg.recipients())
assert set(recipients) == {
other_moderator.email,
moderator_user.email,
superuser.email,
forth.email,
}


def test_generate_magic_approval_link(make_entry, make_user):
entry = make_entry()
moderator = make_user(groups={"moderator": ["news.*"]}, email="mod@x.com")
url = generate_magic_approval_link(entry.slug, moderator.id)

dummy_token = "dummy-token"
expected_base_url = (
reverse("news-magic-approve", kwargs={"token": dummy_token})
.replace(dummy_token, "")
.rstrip("/")
)
assert url.startswith(expected_base_url)

token = url.split(expected_base_url)[-1].strip("/")
serializer = URLSafeTimedSerializer(settings.SECRET_KEY)
data = serializer.loads(token, salt=NEWS_APPROVAL_SALT)

assert data["entry_slug"] == entry.slug
assert data["moderator_id"] == moderator.id


@pytest.mark.parametrize("model_class", NEWS_MODELS)
48 changes: 45 additions & 3 deletions news/views.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from datetime import timedelta

from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.humanize.templatetags import humanize
from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import redirect
from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden
from django.shortcuts import redirect, get_object_or_404
from django.template.defaultfilters import date as datefilter
from django.urls import reverse_lazy
from django.utils.http import url_has_allowed_host_and_scheme
@@ -21,8 +23,10 @@
View,
)
from django.views.generic.detail import SingleObjectMixin
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData

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

User = get_user_model()


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

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


class EntryModerationDetailView(LoginRequiredMixin, EntryDetailView): ...


class EntryModerationMagicApproveView(View):
"""Approve a news entry without requiring moderator login."""

def get(self, request, token, *args, **kwargs):
serializer = URLSafeTimedSerializer(settings.SECRET_KEY)
try:
data = serializer.loads(
token, salt=NEWS_APPROVAL_SALT, max_age=MAGIC_LINK_EXPIRATION
)
entry_slug = data["entry_slug"]
moderator_id = data["moderator_id"]
moderator = User.objects.get(id=moderator_id)
except SignatureExpired:
message = _("This link has expired.")
if not request.user.is_authenticated():
message += _(" Please login to continue.")
messages.warning(request, message)
return redirect(reverse_lazy("news-moderate"), permanent=True)
except (BadData, User.DoesNotExist):
return HttpResponseForbidden("Invalid magic link.")

entry = get_object_or_404(Entry, slug=entry_slug)

try:
entry.approve(moderator)
messages.success(request, _("This entry has been approved."))
except Entry.AlreadyApprovedError:
messages.warning(request, _("This entry has already been approved."))

return redirect(entry, permanent=True)


class EntryCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = None
form_class = None
1 change: 1 addition & 0 deletions requirements.in
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@ psycogreen
gevent==24.2.1
gunicorn
interrogate
itsdangerous
psycopg2-binary
whitenoise
django-click
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -188,6 +188,8 @@ interrogate==1.7.0
# via -r ./requirements.in
ipython==8.28.0
# via -r ./requirements.in
itsdangerous==2.2.0
# via -r ./requirements.in
jedi==0.19.1
# via ipython
jmespath==1.0.1
Loading