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

Add send email admin actions #64

Merged
merged 2 commits into from
Dec 1, 2024
Merged
Show file tree
Hide file tree
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
57 changes: 57 additions & 0 deletions huntsite/emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from io import StringIO

from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from loguru import logger
import markdown
from markdown import Markdown


# https://stackoverflow.com/a/54923798
def unmark_element(element, stream=None):
"""Recursive function to convert markdown to plain text."""
if stream is None:
stream = StringIO()
if element.text:
stream.write(element.text)
for sub in element:
unmark_element(sub, stream)
if element.tag == "p":
# Linebreaks after paragraphs
stream.write("\n")
if element.tag == "a":
# Add the URL after the link text
stream.write(f" ({element.get('href')})")
if element.tail:
stream.write(element.tail)
return stream.getvalue()


# patching Markdown
markdown.Markdown.output_formats["plain"] = unmark_element
__md = Markdown(output_format="plain")
__md.stripTopLevelTags = False


def unmark(text):
return __md.convert(text)


def send_email(subject, message, recipient_list):
"""Send email to a list of recipients as BCC, with a markdown message converted to both HTML
and plaintext."""
logger.info(f"Sending email with subject '{subject}' to {len(recipient_list)} recipients.")

message_html = render_to_string("email.html", {"content": markdown.markdown(message)})
message_plain = unmark(message)

email = EmailMultiAlternatives(
subject=subject,
body=message_plain,
to=(settings.EMAIL_REPLY_TO,),
reply_to=(settings.EMAIL_REPLY_TO,),
bcc=recipient_list,
)
email.attach_alternative(message_html, "text/html")
email.send()
30 changes: 30 additions & 0 deletions huntsite/teams/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from allauth.account.admin import EmailAddressAdmin as AllAuthEmailAddressAdmin
from allauth.account.models import EmailAddress
from django.contrib import admin
from django.utils.safestring import mark_safe
from django_admin_action_forms import action_with_form
from django_no_queryset_admin_actions import NoQuerySetAdminActionsMixin

from huntsite.admin import UneditableAsReadOnlyAdminMixin
from huntsite.emails import send_email
from huntsite.teams.forms import SendEmailAdminForm
import huntsite.teams.models as models
from huntsite.teams.services import user_clear_password, user_deactivate

Expand Down Expand Up @@ -64,3 +70,27 @@ class FlairAdmin(admin.ModelAdmin):
@mark_safe
def icon_safe(self, obj):
return obj.icon


@action_with_form(SendEmailAdminForm, description="Send email to selected email addresses")
def send_email_to_selected(modeladmin, request, queryset, data):
recipients = queryset.values_list("email", flat=True)
send_email(subject=data["subject"], message=data["message"], recipient_list=recipients)
modeladmin.message_user(request, f"Email sent to selected {queryset.count()} addresses.")


@action_with_form(SendEmailAdminForm, description="Send email to all email addresses")
def send_email_to_all(modeladmin, request, data):
queryset = EmailAddress.objects.all()
recipients = queryset.values_list("email", flat=True)
send_email(subject=data["subject"], message=data["message"], recipient_list=recipients)
modeladmin.message_user(request, f"Email sent to all ({queryset.count()}) addresses.")


admin.site.unregister(EmailAddress) # Unregister allauth's default admin


@admin.register(EmailAddress)
class EmailAddressAdmin(NoQuerySetAdminActionsMixin, AllAuthEmailAddressAdmin):
actions = [send_email_to_selected, send_email_to_all]
no_queryset_actions = [send_email_to_all]
8 changes: 8 additions & 0 deletions huntsite/teams/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,11 @@ def users(obj, create, extracted, **kwargs):
if extracted:
obj.users.add(*extracted)
obj.save()


class EmailAddressFactory(factory.django.DjangoModelFactory):
class Meta:
model = "account.EmailAddress"

user = factory.SubFactory(UserFactory)
email = factory.Faker("email")
9 changes: 9 additions & 0 deletions huntsite/teams/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Field, Layout
from django import forms
from django_admin_action_forms import AdminActionForm

from huntsite.teams import models

Expand Down Expand Up @@ -100,3 +101,11 @@ def add_success_message(self):

def add_no_changes_message(self):
self.helper.layout[-1].fields.append(HTML(self._NO_CHANGES_MESSAGE))


class SendEmailAdminForm(AdminActionForm):
subject = forms.CharField(max_length=255)
message = forms.CharField(widget=forms.Textarea)

class Meta:
list_objects = True
22 changes: 22 additions & 0 deletions huntsite/tests.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from textwrap import dedent

from bs4 import BeautifulSoup
from django.conf import settings
from django.test import Client
from django.utils import timezone
from metadata_parser import MetadataParser
import pytest

from huntsite.emails import unmark
from huntsite.puzzles.factories import MetapuzzleInfoFactory, PuzzleFactory
from huntsite.puzzles.services import guess_submit
from huntsite.teams.factories import UserFactory
Expand Down Expand Up @@ -190,3 +193,22 @@ def test_discord_server_link(monkeypatch):
assert response.context.get("DISCORD_SERVER_LINK")
soup = BeautifulSoup(response.content, "html.parser")
assert soup.find("a", attrs={"id": "discord-server-link"})


def test_markdown_unmark():
message = "foo [bar](https://www.adventhunt.com) baz"
expected = "foo bar (https://www.adventhunt.com) baz"
assert unmark(message) == expected

message = dedent("""\
Here's a first **paragraph**. There's a [link](https://www.adventhunt.com) in it.

And here's a _second_ paragraph.
""")
expected = dedent("""\
Here's a first paragraph. There's a link (https://www.adventhunt.com) in it.

And here's a second paragraph.
""").strip()
output = unmark(message)
assert output == expected, output
5 changes: 5 additions & 0 deletions project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ class DeployEnvironment(StrEnum):
"crispy_forms",
"crispy_bulma",
"debug_toolbar",
"django_admin_action_forms",
"django_no_queryset_admin_actions",
"robots",
# Local apps
"huntsite",
Expand Down Expand Up @@ -387,8 +389,11 @@ class DeployEnvironment(StrEnum):
DEFAULT_FROM_EMAIL = f"{EMAIL_FROM_USERNAME}@{EMAIL_FROM_DOMAIN}"
SERVER_EMAIL = DEFAULT_FROM_EMAIL

EMAIL_REPLY_TO = env("EMAIL_REPLY_TO", DEFAULT_FROM_EMAIL)

logger.info("Using email backend: " + EMAIL_BACKEND)
logger.info("Server emails will be sent from: " + DEFAULT_FROM_EMAIL)
logger.info("Server emails will have reply-to address: " + EMAIL_REPLY_TO)

## Error and Performance Monitoring / Sentry

Expand Down
6 changes: 6 additions & 0 deletions requirements/demo.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ django==5.0.4
# -r requirements/deploy.txt
# crispy-bulma
# dj-database-url
# django-admin-action-forms
# django-allauth
# django-anymail
# django-crispy-forms
# django-debug-toolbar
# django-no-queryset-admin-actions
django-admin-action-forms==1.2.4
# via -r requirements/deploy.txt
django-allauth==0.63.6
# via -r requirements/deploy.txt
django-anymail==11.1
Expand All @@ -46,6 +50,8 @@ django-crispy-forms==2.2
# crispy-bulma
django-debug-toolbar==4.4.6
# via -r requirements/deploy.txt
django-no-queryset-admin-actions==1.1.0
# via -r requirements/deploy.txt
django-robots==6.1
# via -r requirements/deploy.txt
environs==11.0.0
Expand Down
2 changes: 2 additions & 0 deletions requirements/deploy.in
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
crispy-bulma
django
django-admin-action-forms
django-allauth
django-anymail[mailgun]
django-crispy-forms
django-debug-toolbar
django-no-queryset-admin-actions
django-robots>=4.0.0
environs[django]
gunicorn
Expand Down
6 changes: 6 additions & 0 deletions requirements/deploy.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,15 @@ django==5.0.4
# -r requirements/deploy.in
# crispy-bulma
# dj-database-url
# django-admin-action-forms
# django-allauth
# django-anymail
# django-crispy-forms
# django-debug-toolbar
# django-no-queryset-admin-actions
# sentry-sdk
django-admin-action-forms==1.2.4
# via -r requirements/deploy.in
django-allauth==0.63.6
# via -r requirements/deploy.in
django-anymail==11.1
Expand All @@ -40,6 +44,8 @@ django-crispy-forms==2.2
# crispy-bulma
django-debug-toolbar==4.4.6
# via -r requirements/deploy.in
django-no-queryset-admin-actions==1.1.0
# via -r requirements/deploy.in
django-robots==6.1
# via -r requirements/deploy.in
environs==11.0.0
Expand Down
6 changes: 6 additions & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,14 @@ django==5.0.4
# -r requirements/demo.txt
# crispy-bulma
# dj-database-url
# django-admin-action-forms
# django-allauth
# django-anymail
# django-crispy-forms
# django-debug-toolbar
# django-no-queryset-admin-actions
django-admin-action-forms==1.2.4
# via -r requirements/demo.txt
django-allauth==0.63.6
# via -r requirements/demo.txt
django-anymail==11.1
Expand All @@ -78,6 +82,8 @@ django-crispy-forms==2.2
# crispy-bulma
django-debug-toolbar==4.4.6
# via -r requirements/demo.txt
django-no-queryset-admin-actions==1.1.0
# via -r requirements/demo.txt
django-robots==6.1
# via -r requirements/demo.txt
djlint==1.34.1
Expand Down
17 changes: 17 additions & 0 deletions templates/email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{# djlint:off H016, H021, H030, H031 #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0;
padding: 0;
font-family: Arial, sans-serif;
line-height: 1.15;
color: #000;
background-color: #fff">
<div>{{ content | safe }}</div>
</body>
</html>
{# djlint: on #}
Loading