Skip to content

Commit

Permalink
Merge branch 'rbeeston/master-develop-icons' into master-develop-icons
Browse files Browse the repository at this point in the history
  • Loading branch information
daveoconnor authored Jan 16, 2025
2 parents 36473bc + 7468f54 commit 46d1043
Show file tree
Hide file tree
Showing 11 changed files with 397 additions and 43 deletions.
2 changes: 1 addition & 1 deletion config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
12 changes: 12 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@
EntryDeleteView,
EntryDetailView,
EntryListView,
EntryModerationDetailView,
EntryModerationListView,
EntryModerationMagicApproveView,
EntryUpdateView,
LinkCreateView,
LinkListView,
Expand Down Expand Up @@ -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/",
Expand Down
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()

Expand Down Expand Up @@ -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):
Expand Down
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

Expand Down Expand Up @@ -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
Expand All @@ -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="[email protected]")
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)
Expand Down
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
Expand All @@ -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 (
Expand All @@ -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."""
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ psycogreen
gevent==24.2.1
gunicorn
interrogate
itsdangerous
psycopg2-binary
whitenoise
django-click
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 46d1043

Please sign in to comment.