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:
@@ -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",
|
||||
|
||||
92
management/email_previews.py
Normal file
92
management/email_previews.py
Normal 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}
|
||||
@@ -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 %}
|
||||
|
||||
43
management/templates/management/email_previews.html
Normal file
43
management/templates/management/email_previews.html
Normal 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 %}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user