Move email previews onto the Club identity page; add PDF previews

Three top-level tabs (Identity/Email/PDF) replace the standalone
Settings > Email previews page and the small mock that used to live
inside the Identity tab's own Email sub-tab. Email and PDF now show
every real branded email/generated PDF this club can send, rendered
with sample data via a shared _document_preview_card.html partial
(email cards keep the HTML/plain-text toggle; PDF cards -- the
underlying HTML WeasyPrint would turn into a PDF, shown directly
rather than round-tripping through WeasyPrint -- don't need one).

Added a password-reset email preview (previously missing from the
audit -- it's allauth's own send path, not one of this app's, so it
didn't show up in the earlier grep for EmailMultiAlternatives/.send()
calls). Dropped parent-claim-approved: not directly used right now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 08:52:52 +02:00
parent 6d69ff7230
commit 3ff882d705
12 changed files with 423 additions and 310 deletions

View File

@@ -1811,10 +1811,12 @@
background: var(--color-paper);
}
/* Live preview tabs -- a real (if minimal) tab control: click toggles which
.preview-panel is shown (club_settings.html's own inline script). App and
Website share one tab since the mobile app isn't built yet and the two
mocks were already always shown together underneath it. */
/* A real (if minimal) tab control: click toggles which panel is shown --
club_settings.html's own inline script uses this for its top-level
Identity/Email/PDF tabs. The Identity tab's own phone/website mock has
no tab of its own; the two are simply shown together (the mobile app
isn't built yet, and D9's separate App/Website tabs would only ever
have shown the same two mocks anyway). */
.preview-tab {
padding: 0.3125rem 0.625rem;
font-family: var(--font-display);
@@ -2054,58 +2056,4 @@
.preview-brand-dot-neutral {
background: var(--color-dim);
}
/* The Email tab -- a generic sample, not any one real message. Shaped like
templates/email/_base.html's actual output (plain white header with a
coloured crest badge, a primary-coloured button in the body) rather than
the phone/website mocks' solid colour bar: that's genuinely how every
branded email (club/templates/club/email/*.html, members/.../claim_approved.html)
looks, so this preview is accurate, not another approximation. */
.preview-email {
overflow: hidden;
border: 1px solid var(--color-line);
border-radius: 0.625rem;
}
.preview-email-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
background: #fff;
border-bottom: 1px solid var(--color-line);
}
.preview-email-crest {
transition: background-color 0.15s ease, color 0.15s ease;
}
.preview-email-name {
font-family: var(--font-display);
font-size: 0.8125rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-ink);
}
.preview-email-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem 0.75rem;
}
.preview-email-button {
align-self: flex-start;
border-radius: 0.25rem;
padding: 0.3125rem 0.75rem;
margin: 0.125rem 0;
font-family: var(--font-display);
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
transition: background-color 0.15s ease, color 0.15s ease;
}
}

View File

@@ -127,7 +127,6 @@ _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",
@@ -173,7 +172,6 @@ _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

@@ -1,15 +1,15 @@
"""Sample renders of every branded email this app can send -- the Settings >
Email previews page (management/templates/management/email_previews.html),
"""Sample renders of every branded email this app can send -- the Email tab
on the Club identity page (management/templates/management/club_settings.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.
club.services.invoicing.send_invoice_email/send_reminder_email, and allauth's
own password-reset flow via templates/account/email/password_reset_key_message.html)
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
@@ -34,16 +34,6 @@ class EmailPreview:
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)
@@ -53,6 +43,21 @@ def _dues_invoice_context(club, request, *, overdue):
return {"club": club, "invoice": invoice, "membership": membership, "member": member, "request": request}
def _password_reset_context(club, request):
# Mirrors allauth.account.internal.flows.password_reset.request_password_reset's
# own context -- current_site is only used by the *stock* .txt template's
# base_message.txt wrapper (our .html override doesn't reference it, but the
# .txt preview does), username only matters when ACCOUNT_LOGIN_METHODS
# includes "username" (it doesn't here -- login is email-only), so it's left
# out entirely rather than faked.
return {
"club": club,
"current_site": SimpleNamespace(name="RosterChief", domain="rosterchief.app"),
"password_reset_url": f"https://{club.slug}.rosterchief.app/accounts/password/reset/key/example/",
"request": request,
}
EMAIL_PREVIEWS = [
EmailPreview(
key="dues_invoice",
@@ -73,16 +78,18 @@ EMAIL_PREVIEWS = [
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,
key="password_reset",
label=_("Password reset"),
description=_("Sent by the “Forgot your password?” link on the sign-in page."),
subject_template="account/email/password_reset_key_subject.txt",
text_template="account/email/password_reset_key_message.txt",
html_template="account/email/password_reset_key_message.html",
build_context=_password_reset_context,
),
]
EMAIL_PREVIEWS_BY_KEY = {preview.key: preview for preview in EMAIL_PREVIEWS}
def render_preview(preview: EmailPreview, *, club, request) -> dict:
context = preview.build_context(club, request)

View File

@@ -0,0 +1,87 @@
"""Sample renders of every PDF this app can generate -- the PDF tab on the
Club identity page (management/templates/management/club_settings.html).
Both PDFs (management.pdf.event_referee_form_pdf, and
club.services.invoicing.invoice_pdf) are themselves just WeasyPrint run over
an HTML template -- see that HTML directly here rather than running
WeasyPrint for a preview: it's the same document, without needing the native
pango/cairo libraries installed just to look at it, and it's exactly what
management.views.EmailPreviewRenderView's sibling PDFPreviewRenderView shows
in an iframe. Same "hand-built sample context, no real DB rows" reasoning as
email_previews.py.
"""
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 _
from .pdf import referee_form_colors
@dataclass(frozen=True)
class PDFPreview:
key: str
label: str
description: str
template: str
build_context: Callable[..., dict]
def _dues_invoice_pdf_context(club, request):
invoice = SimpleNamespace(
number="DUE-2026-00042",
amount=Decimal("45.00"),
due_date=timezone.now().date() + datetime.timedelta(days=14),
sent_at=timezone.now(),
is_paid=False,
sent_to_email="jamie.doe@example.com",
sent_to_guardian=False,
)
membership = SimpleNamespace(season="2026-2027")
return {"club": club, "invoice": invoice, "membership": membership, "member": "Jamie Doe"}
def _referee_form_pdf_context(club, request):
referees = [
SimpleNamespace(display_name="Jamie Doe", fee=Decimal("35.00"), km=Decimal("12.0"), km_rate=Decimal("0.35"), total_payable=Decimal("39.20")),
SimpleNamespace(display_name="Alex Referee", fee=Decimal("35.00"), km=None, km_rate=None, total_payable=Decimal("35.00")),
]
event = SimpleNamespace(
start=timezone.now() + datetime.timedelta(days=7),
teams=SimpleNamespace(all=lambda: [SimpleNamespace(short_name="U16")]),
opponent="Leuven",
external_game_id="",
)
home_location = SimpleNamespace(address="Sportlaan 1", zip_code="1000", city="Brussels")
grand_total = sum((referee.total_payable for referee in referees), Decimal("0"))
return {"club": club, "event": event, "referees": referees, "home_location": home_location, "grand_total": grand_total} | referee_form_colors(club)
PDF_PREVIEWS = [
PDFPreview(
key="dues_invoice",
label=_("Membership invoice"),
description=_("The PDF attached to the membership invoice email, also downloadable from a member's invoice page."),
template="club/dues_invoice_pdf.html",
build_context=_dues_invoice_pdf_context,
),
PDFPreview(
key="referee_form",
label=_("Referee payment form"),
description=_("Downloadable from a game's page, for the club-arranged referee(s) to sign."),
template="management/event_referee_form_pdf.html",
build_context=_referee_form_pdf_context,
),
]
PDF_PREVIEWS_BY_KEY = {preview.key: preview for preview in PDF_PREVIEWS}
def render_pdf_preview(preview: PDFPreview, *, club, request) -> str:
return render_to_string(preview.template, preview.build_context(club, request))

View File

@@ -0,0 +1,34 @@
{% load i18n %}
{% comment %}
One card in the Club identity page's Email/PDF tabs -- included with
`preview` (a dict: key/label/description, plus subject/text for an email),
`render_url` (the iframe's src, already resolved via {% url %} by the
caller since {% url %} can't take a variable view name here), and
`show_text_toggle` (True for an email, which has a plain-text body to
switch to; False for a PDF, which doesn't).
{% endcomment %}
<div class="card overflow-hidden" data-doc-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>
{% if preview.subject %}
<div class="mt-2 font-mono text-xs text-muted"><span class="text-dim">{% trans "Subject:" %}</span> {{ preview.subject }}</div>
{% endif %}
</div>
{% if show_text_toggle %}
<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>
{% endif %}
</div>
<div class="bg-subhead p-4" data-view-panel="html">
<iframe class="h-[560px] w-full rounded-lg border border-line bg-white" src="{{ render_url }}" loading="lazy" title="{{ preview.label }}"></iframe>
</div>
{% if show_text_toggle %}
<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>
{% endif %}
</div>

View File

@@ -93,7 +93,6 @@
<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

@@ -9,124 +9,128 @@
closely for the Colours card (merged swatch+hex row, side-by-side
contrast-check boxes) and the Logo card (dashed drop zone) -- markup
pulled straight from the design canvas (RosterChief Platform.dc.html),
not the prose summary. The live preview has two tabs rather than D9's
three: App and Website are merged into one (they were always shown
together anyway, and the mobile app isn't built yet -- the phone mock
is the same colours applied ahead of it existing); Email is real and
functional, a generic sample shaped like the actual branded emails this
app sends (club/templates/club/email/*.html, members/.../claim_approved.html),
not a preview of any one specific message. The colour math (content
not the prose summary.
Three top-level tabs, not just a preview card: Identity (the settings
form, plus a small live-updating App & Website mock -- colours from the
unsaved form inputs, via the script below) is D9 as designed. Email and
PDF are real, not mocks -- every branded email (management/email_previews.py)
and generated PDF (management/pdf_previews.py) this club can actually send,
rendered with sample data. Those two read the club's *saved* colours
(server-rendered in an iframe, see EmailPreviewRenderView/PDFPreviewRenderView),
so -- unlike the Identity tab's own mock -- they only reflect a colour
change after it's saved, not as you type. The colour math (content
colour, contrast ratio) mirrors Club._content_color_for exactly, so
what's shown is the real number the server computes on save, not an
approximation.
what's shown on the Identity tab is the real number the server computes
on save, not an approximation.
{% endcomment %}
{% block panel_title %}{% trans "Club identity" %}{% endblock panel_title %}
{% block heading %}{% trans "Club identity" %}{% endblock heading %}
{% block actions %}
<button class="btn btn-primary gap-2" type="submit" form="club-settings-form">{% lucide "save" size=16 %} {% trans "Save changes" %}</button>
<button class="btn btn-primary gap-2" type="submit" form="club-settings-form" data-identity-only>{% lucide "save" size=16 %} {% trans "Save changes" %}</button>
{% endblock actions %}
{% block panel %}
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[1.2fr_1fr]">
<form id="club-settings-form" method="post" enctype="multipart/form-data" class="flex flex-col gap-5">
{% csrf_token %}
<div class="mb-1 flex w-fit overflow-hidden rounded-md border border-line">
<button type="button" class="preview-tab preview-tab-active" data-page-tab="identity">{% trans "Identity" %}</button>
<button type="button" class="preview-tab" data-page-tab="email">{% trans "Email" %}</button>
<button type="button" class="preview-tab" data-page-tab="pdf">{% trans "PDF" %}</button>
</div>
{% for error in form.non_field_errors %}
<div class="alert alert-error">
{% lucide "circle-x" size=18 %}
<span>{{ error }}</span>
</div>
{% endfor %}
<div data-page-panel="identity">
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[1.2fr_1fr]">
<form id="club-settings-form" method="post" enctype="multipart/form-data" class="flex flex-col gap-5">
{% csrf_token %}
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "building-2" size=16 %} {% trans "Club" %}</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.name %}
{% form_field form.legal_name %}
{% form_field form.contact_email %}
{% form_field form.website %}
{% for error in form.non_field_errors %}
<div class="alert alert-error">
{% lucide "circle-x" size=18 %}
<span>{{ error }}</span>
</div>
</div>
</div>
{% endfor %}
<div class="card">
<div class="card-body">
<div class="flex items-center justify-between gap-2">
<div class="card-title">{% lucide "palette" size=16 %} {% trans "Colours" %}</div>
<span class="font-mono text-xs text-dim">{% trans "Accents only" %}</span>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{{ form.primary_color.label|capfirst }}</div>
<label class="club-color-row">
<input type="color" id="primary_color_swatch" class="club-color-swatch" value="{{ form.primary_color.value|default:"#0b1220" }}" aria-label="{% trans "Pick the primary colour" %}">
<input type="text" name="{{ form.primary_color.html_name }}" id="{{ form.primary_color.id_for_label }}" value="{{ form.primary_color.value|default:"" }}" placeholder="#0b1220" class="club-color-hex-input">
</label>
{% if form.primary_color.errors %}<p class="mt-1 text-xs text-club-dark">{{ form.primary_color.errors|join:" " }}</p>{% endif %}
</div>
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{{ form.secondary_color.label|capfirst }}</div>
<label class="club-color-row">
<input type="color" id="secondary_color_swatch" class="club-color-swatch" value="{{ form.secondary_color.value|default:"#e4002b" }}" aria-label="{% trans "Pick the secondary colour" %}">
<input type="text" name="{{ form.secondary_color.html_name }}" id="{{ form.secondary_color.id_for_label }}" value="{{ form.secondary_color.value|default:"" }}" placeholder="#e4002b" class="club-color-hex-input">
</label>
{% if form.secondary_color.errors %}<p class="mt-1 text-xs text-club-dark">{{ form.secondary_color.errors|join:" " }}</p>{% endif %}
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "building-2" size=16 %} {% trans "Club" %}</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.name %}
{% form_field form.legal_name %}
{% form_field form.contact_email %}
{% form_field form.website %}
</div>
</div>
</div>
<div class="mt-4">
<div class="mb-2 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Contrast check" %}</div>
<div class="flex gap-2">
<div id="contrast-box-primary" class="club-contrast-box">
<span id="contrast-label-primary"></span>
<span id="contrast-ratio-primary" class="font-mono"></span>
<div class="card">
<div class="card-body">
<div class="flex items-center justify-between gap-2">
<div class="card-title">{% lucide "palette" size=16 %} {% trans "Colours" %}</div>
<span class="font-mono text-xs text-dim">{% trans "Accents only" %}</span>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{{ form.primary_color.label|capfirst }}</div>
<label class="club-color-row">
<input type="color" id="primary_color_swatch" class="club-color-swatch" value="{{ form.primary_color.value|default:"#0b1220" }}" aria-label="{% trans "Pick the primary colour" %}">
<input type="text" name="{{ form.primary_color.html_name }}" id="{{ form.primary_color.id_for_label }}" value="{{ form.primary_color.value|default:"" }}" placeholder="#0b1220" class="club-color-hex-input">
</label>
{% if form.primary_color.errors %}<p class="mt-1 text-xs text-club-dark">{{ form.primary_color.errors|join:" " }}</p>{% endif %}
</div>
<div id="contrast-box-secondary" class="club-contrast-box">
<span id="contrast-label-secondary"></span>
<span id="contrast-ratio-secondary" class="font-mono"></span>
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{{ form.secondary_color.label|capfirst }}</div>
<label class="club-color-row">
<input type="color" id="secondary_color_swatch" class="club-color-swatch" value="{{ form.secondary_color.value|default:"#e4002b" }}" aria-label="{% trans "Pick the secondary colour" %}">
<input type="text" name="{{ form.secondary_color.html_name }}" id="{{ form.secondary_color.id_for_label }}" value="{{ form.secondary_color.value|default:"" }}" placeholder="#e4002b" class="club-color-hex-input">
</label>
{% if form.secondary_color.errors %}<p class="mt-1 text-xs text-club-dark">{{ form.secondary_color.errors|join:" " }}</p>{% endif %}
</div>
</div>
<div class="mt-4">
<div class="mb-2 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Contrast check" %}</div>
<div class="flex gap-2">
<div id="contrast-box-primary" class="club-contrast-box">
<span id="contrast-label-primary"></span>
<span id="contrast-ratio-primary" class="font-mono"></span>
</div>
<div id="contrast-box-secondary" class="club-contrast-box">
<span id="contrast-label-secondary"></span>
<span id="contrast-ratio-secondary" class="font-mono"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "image" size=16 %} {% trans "Logo" %}</div>
<div class="flex items-end gap-4">
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Crest" %}</div>
<div class="club-logo-dropzone">
{% if club.logo %}
<img class="h-full w-full object-contain p-1.5" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="font-display text-sm font-extrabold text-muted">{{ club.initials }}</span>
{% endif %}
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "image" size=16 %} {% trans "Logo" %}</div>
<div class="flex items-end gap-4">
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Crest" %}</div>
<div class="club-logo-dropzone">
{% if club.logo %}
<img class="h-full w-full object-contain p-1.5" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="font-display text-sm font-extrabold text-muted">{{ club.initials }}</span>
{% endif %}
</div>
</div>
<div class="flex-1">{% form_field form.logo %}</div>
</div>
<div class="flex-1">{% form_field form.logo %}</div>
</div>
</div>
</div>
</form>
</form>
<div class="xl:sticky xl:top-4 xl:self-start">
<div class="card">
<div class="card-body">
<div class="flex items-center justify-between gap-2">
<div class="xl:sticky xl:top-4 xl:self-start">
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "eye" size=16 %} {% trans "Live preview" %}</div>
<div class="flex overflow-hidden rounded-md border border-line">
<button type="button" class="preview-tab preview-tab-active" data-preview-tab="app">{% trans "App & Website" %}</button>
<button type="button" class="preview-tab" data-preview-tab="email">{% trans "Email" %}</button>
</div>
</div>
<div class="mt-3">
<div id="preview-panel-app" class="flex flex-wrap items-start gap-4" data-preview-panel="app">
<div class="mt-3 flex flex-wrap items-start gap-4">
<div class="preview-phone-lg">
<div id="preview-phone-header" class="preview-phone-header">
<div class="preview-phone-header-row">
@@ -173,30 +177,31 @@
<li class="flex items-center gap-2"><span id="preview-dot-1" class="preview-brand-dot"></span>{% trans "Sidebar, active nav item, primary buttons" %}</li>
<li class="flex items-center gap-2"><span id="preview-dot-2" class="preview-brand-dot"></span>{% trans "Public site header and join button" %}</li>
<li class="flex items-center gap-2"><span id="preview-dot-3" class="preview-brand-dot"></span>{% trans "Mobile app header and highlights" %}</li>
<li class="flex items-center gap-2"><span id="preview-dot-4" class="preview-brand-dot"></span>{% trans "Transactional email crest and buttons" %}</li>
<li class="flex items-center gap-2"><span id="preview-dot-4" class="preview-brand-dot"></span>{% trans "Emails and PDFs -- see the Email/PDF tabs above" %}</li>
<li class="flex items-center gap-2"><span class="preview-brand-dot preview-brand-dot-neutral"></span>{% trans "Never: status colours, tables, form fields" %}</li>
</ul>
</div>
</div>
</div>
<div id="preview-panel-email" class="preview-email hidden" data-preview-panel="email">
<div class="preview-email-header">
<span id="preview-email-crest" class="preview-crest preview-crest-sm preview-email-crest">{{ club.initials|default:"CL" }}</span>
<span class="preview-email-name">{{ club.name|default:_("Your club") }}</span>
</div>
<div class="preview-email-body">
<div class="preview-skeleton-line"></div>
<div class="preview-skeleton-line preview-skeleton-line-short"></div>
<span id="preview-email-button" class="preview-email-button">{% trans "View details" %}</span>
<div class="preview-skeleton-line preview-skeleton-line-short"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="hidden flex-col gap-4" data-page-panel="email">
{% for preview in email_previews %}
{% url 'management:email_preview_render' preview.key as render_url %}
{% include "management/_document_preview_card.html" with preview=preview render_url=render_url show_text_toggle=True %}
{% endfor %}
</div>
<div class="hidden flex-col gap-4" data-page-panel="pdf">
{% for preview in pdf_previews %}
{% url 'management:pdf_preview_render' preview.key as render_url %}
{% include "management/_document_preview_card.html" with preview=preview render_url=render_url show_text_toggle=False %}
{% endfor %}
</div>
{% endblock panel %}
{% block extra_body %}
@@ -257,15 +262,6 @@
document.getElementById("preview-website-join").style.background = secondary;
document.getElementById("preview-website-join").style.color = secondaryContent;
// Matches the real branded emails (club/templates/club/email/*.html):
// a plain white header with a secondary-coloured crest badge, and a
// primary-coloured button in the body -- same split as _button.html's
// own bg=primary convention, not the phone/website mocks' solid bar.
document.getElementById("preview-email-crest").style.background = secondary;
document.getElementById("preview-email-crest").style.color = secondaryContent;
document.getElementById("preview-email-button").style.background = primary;
document.getElementById("preview-email-button").style.color = primaryContent;
["preview-dot-1", "preview-dot-2", "preview-dot-3", "preview-dot-4"].forEach((id) => {
document.getElementById(id).style.background = secondary;
});
@@ -294,13 +290,29 @@
updatePreview();
// Live preview tabs -- toggles which .preview-panel is visible, App &
// Website vs Email. Both panels stay in the DOM (and colour-bound) the
// whole time; only which one is hidden changes.
document.querySelectorAll("[data-preview-tab]").forEach((tab) => {
// Top-level page tabs -- Identity/Email/PDF. "Save changes" (the
// topbar action) only applies to the Identity tab's own form, so it
// hides on the other two rather than sitting there doing nothing.
document.querySelectorAll("[data-page-tab]").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll("[data-preview-tab]").forEach((other) => other.classList.toggle("preview-tab-active", other === tab));
document.querySelectorAll("[data-preview-panel]").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.previewPanel !== tab.dataset.previewTab));
document.querySelectorAll("[data-page-tab]").forEach((other) => other.classList.toggle("preview-tab-active", other === tab));
document.querySelectorAll("[data-page-panel]").forEach((panel) => {
const active = panel.dataset.pagePanel === tab.dataset.pageTab;
panel.classList.toggle("hidden", !active);
panel.classList.toggle("flex", active && panel.dataset.pagePanel !== "identity");
});
document.querySelectorAll("[data-identity-only]").forEach((el) => el.classList.toggle("hidden", tab.dataset.pageTab !== "identity"));
});
});
// Email/PDF cards' own HTML/Plain text toggle -- scoped per card so
// several cards on the same tab each keep independent state.
document.querySelectorAll("[data-doc-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));
});
});
});
})();

View File

@@ -1,43 +0,0 @@
{% 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" src="{% url 'management:email_preview_render' preview.key %}" loading="lazy" 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

@@ -28,6 +28,7 @@ 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.pdf_previews import PDF_PREVIEWS
from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
from members.services.claims import children_awaiting_a_parent
@@ -4717,12 +4718,15 @@ class SponsorManagementTests(ManagementTestBase):
class ClubSettingsPreviewTests(ManagementTestBase):
"""The D9-alike live preview on the Club identity page -- see
management/templates/management/club_settings.html."""
management/templates/management/club_settings.html. The Identity tab's
own mock (phone/website, JS colour-bound) is separate from the Email/PDF
tabs (real templates, server-rendered) -- see ClubSettingsDocumentTabTests
for those."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_the_preview_tabs_and_colour_swatches_render(self):
def test_the_page_tabs_and_colour_swatches_render(self):
response = self.club_get("club_settings")
self.assertContains(response, "preview-tab-active")
@@ -4740,18 +4744,12 @@ class ClubSettingsPreviewTests(ManagementTestBase):
response = self.club_get("club_settings")
self.assertContains(response, 'name="website"')
def test_app_and_website_share_one_tab(self):
def test_identity_email_and_pdf_are_the_three_page_tabs(self):
response = self.club_get("club_settings")
self.assertContains(response, "App & Website")
self.assertContains(response, 'data-preview-tab="app"')
self.assertNotContains(response, 'data-preview-tab="website"')
def test_the_email_tab_renders_and_starts_hidden(self):
response = self.club_get("club_settings")
self.assertContains(response, 'data-preview-tab="email"')
self.assertContains(response, 'id="preview-panel-email" class="preview-email hidden"')
self.assertContains(response, 'data-page-tab="identity"')
self.assertContains(response, 'data-page-tab="email"')
self.assertContains(response, 'data-page-tab="pdf"')
def test_saving_still_updates_the_club(self):
response = self.club_post(
@@ -4766,60 +4764,79 @@ class ClubSettingsPreviewTests(ManagementTestBase):
self.assertEqual(self.club.website, "https://ajax-united.example")
class EmailPreviewListViewTests(ManagementTestBase):
"""Settings > Email previews -- see management.email_previews."""
class ClubSettingsDocumentTabTests(ManagementTestBase):
"""The Email and PDF tabs on the Club identity page -- every branded
email (management.email_previews) and generated PDF (management.pdf_previews)
this club can send, rendered against sample data. See
EmailPreviewRenderViewTests/PDFPreviewRenderViewTests for the actual
per-document render each card's iframe loads."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_renders_every_registered_preview(self):
response = self.club_get("email_preview_list")
def test_renders_every_registered_email_and_pdf_preview(self):
response = self.club_get("club_settings")
self.assertEqual(response.status_code, 200)
for preview in EMAIL_PREVIEWS:
self.assertContains(response, preview.label)
for preview in PDF_PREVIEWS:
self.assertContains(response, preview.label)
def test_each_card_links_to_its_own_render_view(self):
response = self.club_get("email_preview_list")
response = self.club_get("club_settings")
for preview in EMAIL_PREVIEWS:
self.assertContains(response, reverse("management:email_preview_render", args=[preview.key]))
for preview in PDF_PREVIEWS:
self.assertContains(response, reverse("management:pdf_preview_render", args=[preview.key]))
def test_the_subject_and_plain_text_body_render(self):
response = self.club_get("email_preview_list")
def test_the_subject_and_plain_text_body_render_for_emails(self):
response = self.club_get("club_settings")
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())
def test_pdf_cards_have_no_plain_text_toggle(self):
# PDFs have no plain-text body to switch to -- unlike an email card,
# a PDF card is just the one iframe. Exactly one toggle button per
# email preview, none contributed by the PDF ones.
response = self.club_get("club_settings")
response = self.club_get("email_preview_list")
self.assertEqual(response.content.decode().count('data-view-btn="text"'), len(EMAIL_PREVIEWS))
def test_claim_approved_was_dropped(self):
# Not directly used right now -- see management/email_previews.py.
response = self.club_get("club_settings")
self.assertNotContains(response, "Parent claim approved")
def test_no_real_data_is_needed(self):
# The whole point: previews render with zero DuesInvoice/Event rows
# in the database.
self.assertFalse(DuesInvoice.objects.exists())
self.assertFalse(Event.objects.filter(club=self.club).exists())
response = self.club_get("club_settings")
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_user = User.objects.create_user(email="coach-doc-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")
response = self.club_get("club_settings")
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 EmailPreviewRenderViewTests(ManagementTestBase):
"""The actual per-email HTML document each card's iframe loads via `src` --
see EmailPreviewListView's own docstring for why this is a same-origin
see EmailPreviewRenderView's own docstring for why this is a same-origin
sub-request rather than a srcdoc="..." attribute."""
def setUp(self):
@@ -4843,10 +4860,16 @@ class EmailPreviewRenderViewTests(ManagementTestBase):
self.assertContains(response, "Ajax United")
self.assertContains(response, "#123456")
def test_the_password_reset_preview_renders(self):
response = self.client.get(reverse("management:email_preview_render", args=["password_reset"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Reset your password")
def test_allows_same_origin_framing(self):
# The site-wide default (SecurityMiddleware, no X_FRAME_OPTIONS override
# in settings.py) is DENY -- this response overrides that specifically,
# since EmailPreviewListView's own iframe needs to load it.
# since the Email tab's own iframe needs to load it.
response = self.client.get(reverse("management:email_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response["X-Frame-Options"], "SAMEORIGIN")
@@ -4869,6 +4892,51 @@ class EmailPreviewRenderViewTests(ManagementTestBase):
self.assertEqual(response.status_code, 403)
class PDFPreviewRenderViewTests(ManagementTestBase):
"""The actual per-PDF HTML document each PDF card's iframe loads via
`src` -- the same underlying HTML WeasyPrint would turn into a PDF, shown
directly rather than round-tripping through WeasyPrint for a preview."""
def setUp(self):
self.client.force_login(self.admin_user)
def test_renders_the_dues_invoice_pdf_html(self):
response = self.client.get(reverse("management:pdf_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<!DOCTYPE html>")
self.assertContains(response, "DUE-2026-00042")
def test_renders_the_referee_form_pdf_html(self):
response = self.client.get(reverse("management:pdf_preview_render", args=["referee_form"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Referee payment form")
self.assertContains(response, "Jamie Doe")
def test_allows_same_origin_framing(self):
response = self.client.get(reverse("management:pdf_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response["X-Frame-Options"], "SAMEORIGIN")
def test_an_unknown_key_404s(self):
response = self.client.get(reverse("management:pdf_preview_render", args=["not-a-real-key"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 404)
def test_non_admin_gets_403(self):
coach_user = User.objects.create_user(email="coach-pdf-render@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="U20", short_name="U20")
position = Position.objects.create(club=self.club, name="Coach16", short_name="C16", staff_position=True)
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
response = self.client.get(reverse("management:pdf_preview_render", args=["dues_invoice"]), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 403)
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,8 +135,8 @@ 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/email-previews/<str:key>/render/", views.EmailPreviewRenderView.as_view(), name="email_preview_render"),
path("settings/pdf-previews/<str:key>/render/", views.PDFPreviewRenderView.as_view(), name="pdf_preview_render"),
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

@@ -54,7 +54,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 .email_previews import EMAIL_PREVIEWS, EMAIL_PREVIEWS_BY_KEY, render_preview
from .forms import (
AddChildForm,
AddParentForm,
@@ -96,6 +96,7 @@ from .forms import (
bulk_add_member_label,
)
from .pdf import PDFExportError, event_referee_form_pdf, membership_list_pdf, referee_form_colors
from .pdf_previews import PDF_PREVIEWS, PDF_PREVIEWS_BY_KEY, render_pdf_preview
from .recurrence_ui import describe_rrule
@@ -3319,49 +3320,35 @@ class ClubSettingsView(ClubAdminRequiredMixin, UpdateView):
def get_success_url(self):
return reverse("management:club_settings")
def get_context_data(self, **kwargs):
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.
The HTML render itself is NOT embedded directly here -- see
EmailPreviewRenderView, which each card's iframe points its `src` at.
An email's markup is full of its own double-quoted style="..." attributes;
dropping that whole document into a srcdoc="..." attribute here relies on
Django's autoescaping to get every one of those quotes right, and depends
on browser-specific handling of an escaped, inherited-CSP srcdoc document
that turned out not to render reliably. A same-origin sub-request sidesteps
all of that -- ordinary HTML delivered as an ordinary response."""
template_name = "management/email_previews.html"
def get_context_data(self, **kwargs):
club = self.request.club
previews = []
email_previews = []
for preview in EMAIL_PREVIEWS:
rendered = render_preview(preview, club=club, request=self.request)
previews.append({"key": preview.key, "label": preview.label, "description": preview.description, "subject": rendered["subject"], "text": rendered["text"]})
return super().get_context_data(previews=previews, **kwargs)
email_previews.append({"key": preview.key, "label": preview.label, "description": preview.description, "subject": rendered["subject"], "text": rendered["text"]})
pdf_previews = [{"key": preview.key, "label": preview.label, "description": preview.description} for preview in PDF_PREVIEWS]
return super().get_context_data(email_previews=email_previews, pdf_previews=pdf_previews, **kwargs)
@method_decorator(xframe_options_sameorigin, name="get")
class EmailPreviewRenderView(ClubAdminRequiredMixin, View):
"""The actual HTML document for one preview, served at its own URL so
EmailPreviewListView's iframe can `src` it directly rather than smuggling
it through a srcdoc="..." attribute -- see that view's docstring for why.
"""The actual HTML document for one email preview, served at its own URL
so the Email tab's iframe can `src` it directly rather than smuggling it
through a srcdoc="..." attribute. An email's markup is full of its own
double-quoted style="..." attributes; dropping that whole document into
a srcdoc="..." attribute relies on Django's autoescaping to get every one
of those quotes right, and on browser-specific handling of an escaped,
inherited-CSP srcdoc document that turned out not to render reliably. A
same-origin sub-request sidesteps all of that -- ordinary HTML delivered
as an ordinary response.
xframe_options_sameorigin overrides the site-wide X-Frame-Options: DENY
(settings.py has no X_FRAME_OPTIONS override, so SecurityMiddleware's
default applies everywhere else): this response only ever needs to be
framed by the page that links to it, on the same origin."""
def get(self, request, key):
preview = next((preview for preview in EMAIL_PREVIEWS if preview.key == key), None)
preview = EMAIL_PREVIEWS_BY_KEY.get(key)
if preview is None:
raise Http404
@@ -3369,6 +3356,22 @@ class EmailPreviewRenderView(ClubAdminRequiredMixin, View):
return HttpResponse(rendered["html"], content_type="text/html; charset=utf-8")
@method_decorator(xframe_options_sameorigin, name="get")
class PDFPreviewRenderView(ClubAdminRequiredMixin, View):
"""The PDF tab's sibling of EmailPreviewRenderView -- the underlying HTML
WeasyPrint would turn into a PDF (see pdf_previews.PDF_PREVIEWS's own
docstring for why that's shown directly rather than actually running
WeasyPrint), served the same same-origin-iframe-friendly way."""
def get(self, request, key):
preview = PDF_PREVIEWS_BY_KEY.get(key)
if preview is None:
raise Http404
html = render_pdf_preview(preview, club=request.club, request=request)
return HttpResponse(html, content_type="text/html; charset=utf-8")
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

File diff suppressed because one or more lines are too long