Add a Settings > Email previews page showing every branded email with sample data

Renders the real templates every send function uses (claim approved,
membership invoice, invoice reminder) against hand-built placeholder
context -- no real Member/ClubMembership/DuesInvoice row needed, so
nothing here can leak real data. Each card shows the subject line and
toggles between the HTML render (in an iframe, so the email's own
markup can't clash with the page's) and the plain-text body, using
the same bg-steel pill-toggle pattern as the news preview's NL/EN
switch (generalised to .view-toggle-btn, kept .news-lang-btn as an
alias). Admin-only, alongside Club identity in Settings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-20 22:54:08 +02:00
parent 7ab7ac1982
commit d5e396102e
10 changed files with 235 additions and 11 deletions

View File

@@ -127,6 +127,7 @@ _NAV_SECTIONS = {
"form_list": "form_list",
"submission_list": "form_list",
"club_settings": "club_settings",
"email_preview_list": "email_preview_list",
"onboarding_requirement_list": "onboarding_requirement_list",
"onboarding_requirement_create": "onboarding_requirement_list",
"onboarding_requirement_update": "onboarding_requirement_list",
@@ -172,6 +173,7 @@ _TOP_SECTION = {
"invoice_list": "finance",
"form_list": "settings",
"club_settings": "settings",
"email_preview_list": "settings",
"onboarding_requirement_list": "settings",
"role_list": "settings",
"position_list": "settings",

View File

@@ -0,0 +1,92 @@
"""Sample renders of every branded email this app can send -- the Settings >
Email previews page (management/templates/management/email_previews.html),
so a club can see exactly what a member/parent receives without anything
actually being sent.
Each entry renders the *real* templates the real send functions use (see
members.services.claims.send_claim_approved_email and
club.services.invoicing.send_invoice_email/send_reminder_email) against a
hand-built sample context -- never a real Member/ClubMembership/DuesInvoice
row, so this needs nothing from the database beyond the current club itself,
and can't leak anything real. Adding a new branded email later means adding
one entry here, not touching the view or template.
"""
import datetime
from collections.abc import Callable
from dataclasses import dataclass
from decimal import Decimal
from types import SimpleNamespace
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@dataclass(frozen=True)
class EmailPreview:
key: str
label: str
description: str
subject_template: str
text_template: str
html_template: str
build_context: Callable[..., dict]
def _claim_approved_context(club, request):
return {
"club": club,
"child": "Jamie Doe",
"parent_first_name": "Alex",
"set_password_url": f"https://{club.slug}.rosterchief.app/accounts/password/reset/key/example/",
"request": request,
}
def _dues_invoice_context(club, request, *, overdue):
today = timezone.now().date()
due_date = today - datetime.timedelta(days=5) if overdue else today + datetime.timedelta(days=14)
invoice = SimpleNamespace(number="DUE-2026-00042", amount=Decimal("45.00"), due_date=due_date)
membership = SimpleNamespace(season="2026-2027")
member = SimpleNamespace(first_name="Jamie")
return {"club": club, "invoice": invoice, "membership": membership, "member": member, "request": request}
EMAIL_PREVIEWS = [
EmailPreview(
key="dues_invoice",
label=_("Membership invoice"),
description=_("Sent when a staff member clicks “Send invoice” for one or more members on the Dues & billing page."),
subject_template="club/email/dues_invoice_subject.txt",
text_template="club/email/dues_invoice.txt",
html_template="club/email/dues_invoice.html",
build_context=lambda club, request: _dues_invoice_context(club, request, overdue=False),
),
EmailPreview(
key="dues_invoice_reminder",
label=_("Invoice reminder"),
description=_("Sent by the “Send reminders” button, once per invoice that's still unpaid past its due date."),
subject_template="club/email/dues_invoice_reminder_subject.txt",
text_template="club/email/dues_invoice_reminder.txt",
html_template="club/email/dues_invoice_reminder.html",
build_context=lambda club, request: _dues_invoice_context(club, request, overdue=True),
),
EmailPreview(
key="claim_approved",
label=_("Parent claim approved"),
description=_("Sent when a staff member approves a parent or guardian's claim to a child, from the Parent claims page."),
subject_template="members/email/claim_approved_subject.txt",
text_template="members/email/claim_approved.txt",
html_template="members/email/claim_approved.html",
build_context=_claim_approved_context,
),
]
def render_preview(preview: EmailPreview, *, club, request) -> dict:
context = preview.build_context(club, request)
subject = " ".join(render_to_string(preview.subject_template, context).split())
text_body = render_to_string(preview.text_template, context).strip()
html_body = render_to_string(preview.html_template, context)
return {"subject": subject, "text": text_body, "html": html_body}

View File

@@ -93,6 +93,7 @@
<div class="flex flex-col">
{% if is_club_admin %}
<a class="nav-subitem {% if nav == 'club_settings' %}active{% endif %}" href="{% url 'management:club_settings' %}">{% trans "Club identity" %}</a>
<a class="nav-subitem {% if nav == 'email_preview_list' %}active{% endif %}" href="{% url 'management:email_preview_list' %}">{% trans "Email previews" %}</a>
{% endif %}
<a class="nav-subitem {% if nav == 'onboarding_requirement_list' %}active{% endif %}" href="{% url 'management:onboarding_requirement_list' %}">{% trans "Onboarding requirements" %}</a>
{% if is_club_admin %}

View File

@@ -0,0 +1,43 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "Email previews" %}{% endblock heading %}
{% block topbar_context %}<span class="text-sm text-muted">{% trans "Exactly what a member or parent receives -- with sample data, nothing is sent." %}</span>{% endblock topbar_context %}
{% block panel %}
{% for preview in previews %}
<div class="card overflow-hidden" data-email-card>
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-line px-4.5 py-3.5">
<div>
<div class="font-display text-sm font-extrabold tracking-wide text-ink uppercase">{{ preview.label }}</div>
<p class="mt-1 max-w-2xl text-sm text-muted">{{ preview.description }}</p>
<div class="mt-2 font-mono text-xs text-muted"><span class="text-dim">{% trans "Subject:" %}</span> {{ preview.subject }}</div>
</div>
<div class="flex items-center gap-1 rounded-full bg-steel p-1">
<button type="button" class="view-toggle-btn active" data-view-btn="html">{% trans "Email" %}</button>
<button type="button" class="view-toggle-btn" data-view-btn="text">{% trans "Plain text" %}</button>
</div>
</div>
<div class="bg-subhead p-4" data-view-panel="html">
<iframe class="h-[560px] w-full rounded-lg border border-line bg-white" srcdoc="{{ preview.html }}" title="{{ preview.label }}"></iframe>
</div>
<pre class="hidden overflow-x-auto p-4.5 font-mono text-sm whitespace-pre-wrap text-ink" data-view-panel="text">{{ preview.text }}</pre>
</div>
{% endfor %}
{% endblock panel %}
{% block extra_body %}
<script>
(() => {
document.querySelectorAll("[data-email-card]").forEach((card) => {
card.querySelectorAll("[data-view-btn]").forEach((button) => {
button.addEventListener("click", () => {
card.querySelectorAll("[data-view-btn]").forEach((b) => b.classList.toggle("active", b === button));
card.querySelectorAll("[data-view-panel]").forEach((el) => el.classList.toggle("hidden", el.dataset.viewPanel !== button.dataset.viewBtn));
});
});
});
})();
</script>
{% endblock extra_body %}

View File

@@ -26,6 +26,7 @@ from events.models import Attendance, Competition, Event, EventReferee, EventSer
from events.services.rbihf_import import RBIHFImportError
from events.services.recurrence import detach_occurrence, generate_occurrences
from management.bulk_import import TEMPLATE_COLUMNS
from management.email_previews import EMAIL_PREVIEWS
from management.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf
from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
@@ -4765,6 +4766,61 @@ class ClubSettingsPreviewTests(ManagementTestBase):
self.assertEqual(self.club.website, "https://ajax-united.example")
class EmailPreviewListViewTests(ManagementTestBase):
"""Settings > Email previews -- see management.email_previews."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_renders_every_registered_preview(self):
response = self.club_get("email_preview_list")
self.assertEqual(response.status_code, 200)
for preview in EMAIL_PREVIEWS:
self.assertContains(response, preview.label)
def test_the_html_render_uses_the_clubs_own_branding(self):
self.club.name = "Ajax United"
self.club.secondary_color = "#123456"
self.club.save(update_fields=["name", "secondary_color"])
response = self.club_get("email_preview_list")
self.assertContains(response, "Ajax United")
self.assertContains(response, "#123456")
def test_the_subject_and_plain_text_body_render(self):
response = self.club_get("email_preview_list")
self.assertContains(response, "Subject:")
self.assertContains(response, "DUE-2026-00042")
def test_no_real_invoice_or_claim_data_is_needed(self):
# The whole point: a preview renders with zero DuesInvoice/ParentClaim
# rows in the database.
self.assertFalse(DuesInvoice.objects.exists())
response = self.club_get("email_preview_list")
self.assertEqual(response.status_code, 200)
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-email-preview@example.com", password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
team = Team.objects.create(club=self.club, name="U18", short_name="U18")
position = Position.objects.create(club=self.club, name="Coach14", short_name="C14", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.club_get("email_preview_list")
self.assertEqual(response.status_code, 403)
def test_nav_entry_is_admin_only(self):
admin_response = self.club_get("club_settings")
self.assertContains(admin_response, reverse("management:email_preview_list"))
class BillingEndingBannerTests(ManagementTestBase):
"""The club dashboard's "billing is about to stop" warning -- see
management.views.HomeView and management/templates/management/home.html.

View File

@@ -135,6 +135,7 @@ urlpatterns = [
path("forms/<uuid:pk>/submissions/", views.SubmissionListView.as_view(), name="submission_list"),
# Settings (admin only)
path("settings/", views.ClubSettingsView.as_view(), name="club_settings"),
path("settings/email-previews/", views.EmailPreviewListView.as_view(), name="email_preview_list"),
path("settings/onboarding-requirements/", views.OnboardingRequirementListView.as_view(), name="onboarding_requirement_list"),
path("settings/onboarding-requirements/new/", views.OnboardingRequirementCreateView.as_view(), name="onboarding_requirement_create"),
path("settings/onboarding-requirements/<uuid:pk>/edit/", views.OnboardingRequirementUpdateView.as_view(), name="onboarding_requirement_update"),

View File

@@ -52,6 +52,7 @@ from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment
from teams.services import eligible_roster_members
from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook
from .email_previews import EMAIL_PREVIEWS, render_preview
from .forms import (
AddChildForm,
AddParentForm,
@@ -3320,6 +3321,21 @@ class ClubSettingsView(ClubAdminRequiredMixin, UpdateView):
return super().get_context_data(**kwargs)
class EmailPreviewListView(ClubAdminRequiredMixin, TemplateView):
"""See email_previews.EMAIL_PREVIEWS's own docstring -- every branded email
this app can send, rendered against sample data for this club so an admin
can see exactly what a member/parent would receive without sending
anything. Same access level as Club identity: this is a branding/comms
concern, not day-to-day people/roster work."""
template_name = "management/email_previews.html"
def get_context_data(self, **kwargs):
club = self.request.club
previews = [{"key": preview.key, "label": preview.label, "description": preview.description, **render_preview(preview, club=club, request=self.request)} for preview in EMAIL_PREVIEWS]
return super().get_context_data(previews=previews, **kwargs)
class OnboardingRequirementListView(MemberAdminRequiredMixin, ListView):
"""What a club requires from every member after they sign up or renew (a
photo, a medical certificate, ...) -- see club/models.py's OnboardingRequirement