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:
@@ -1811,10 +1811,12 @@
|
|||||||
background: var(--color-paper);
|
background: var(--color-paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Live preview tabs -- a real (if minimal) tab control: click toggles which
|
/* A real (if minimal) tab control: click toggles which panel is shown --
|
||||||
.preview-panel is shown (club_settings.html's own inline script). App and
|
club_settings.html's own inline script uses this for its top-level
|
||||||
Website share one tab since the mobile app isn't built yet and the two
|
Identity/Email/PDF tabs. The Identity tab's own phone/website mock has
|
||||||
mocks were already always shown together underneath it. */
|
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 {
|
.preview-tab {
|
||||||
padding: 0.3125rem 0.625rem;
|
padding: 0.3125rem 0.625rem;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
@@ -2054,58 +2056,4 @@
|
|||||||
.preview-brand-dot-neutral {
|
.preview-brand-dot-neutral {
|
||||||
background: var(--color-dim);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ _NAV_SECTIONS = {
|
|||||||
"form_list": "form_list",
|
"form_list": "form_list",
|
||||||
"submission_list": "form_list",
|
"submission_list": "form_list",
|
||||||
"club_settings": "club_settings",
|
"club_settings": "club_settings",
|
||||||
"email_preview_list": "email_preview_list",
|
|
||||||
"onboarding_requirement_list": "onboarding_requirement_list",
|
"onboarding_requirement_list": "onboarding_requirement_list",
|
||||||
"onboarding_requirement_create": "onboarding_requirement_list",
|
"onboarding_requirement_create": "onboarding_requirement_list",
|
||||||
"onboarding_requirement_update": "onboarding_requirement_list",
|
"onboarding_requirement_update": "onboarding_requirement_list",
|
||||||
@@ -173,7 +172,6 @@ _TOP_SECTION = {
|
|||||||
"invoice_list": "finance",
|
"invoice_list": "finance",
|
||||||
"form_list": "settings",
|
"form_list": "settings",
|
||||||
"club_settings": "settings",
|
"club_settings": "settings",
|
||||||
"email_preview_list": "settings",
|
|
||||||
"onboarding_requirement_list": "settings",
|
"onboarding_requirement_list": "settings",
|
||||||
"role_list": "settings",
|
"role_list": "settings",
|
||||||
"position_list": "settings",
|
"position_list": "settings",
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"""Sample renders of every branded email this app can send -- the Settings >
|
"""Sample renders of every branded email this app can send -- the Email tab
|
||||||
Email previews page (management/templates/management/email_previews.html),
|
on the Club identity page (management/templates/management/club_settings.html),
|
||||||
so a club can see exactly what a member/parent receives without anything
|
so a club can see exactly what a member/parent receives without anything
|
||||||
actually being sent.
|
actually being sent.
|
||||||
|
|
||||||
Each entry renders the *real* templates the real send functions use (see
|
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, and allauth's
|
||||||
club.services.invoicing.send_invoice_email/send_reminder_email) against a
|
own password-reset flow via templates/account/email/password_reset_key_message.html)
|
||||||
hand-built sample context -- never a real Member/ClubMembership/DuesInvoice
|
against a hand-built sample context -- never a real Member/ClubMembership/
|
||||||
row, so this needs nothing from the database beyond the current club itself,
|
DuesInvoice row, so this needs nothing from the database beyond the current
|
||||||
and can't leak anything real. Adding a new branded email later means adding
|
club itself, and can't leak anything real. Adding a new branded email later
|
||||||
one entry here, not touching the view or template.
|
means adding one entry here, not touching the view or template.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -34,16 +34,6 @@ class EmailPreview:
|
|||||||
build_context: Callable[..., dict]
|
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):
|
def _dues_invoice_context(club, request, *, overdue):
|
||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
due_date = today - datetime.timedelta(days=5) if overdue else today + datetime.timedelta(days=14)
|
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}
|
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 = [
|
EMAIL_PREVIEWS = [
|
||||||
EmailPreview(
|
EmailPreview(
|
||||||
key="dues_invoice",
|
key="dues_invoice",
|
||||||
@@ -73,16 +78,18 @@ EMAIL_PREVIEWS = [
|
|||||||
build_context=lambda club, request: _dues_invoice_context(club, request, overdue=True),
|
build_context=lambda club, request: _dues_invoice_context(club, request, overdue=True),
|
||||||
),
|
),
|
||||||
EmailPreview(
|
EmailPreview(
|
||||||
key="claim_approved",
|
key="password_reset",
|
||||||
label=_("Parent claim approved"),
|
label=_("Password reset"),
|
||||||
description=_("Sent when a staff member approves a parent or guardian's claim to a child, from the Parent claims page."),
|
description=_("Sent by the “Forgot your password?” link on the sign-in page."),
|
||||||
subject_template="members/email/claim_approved_subject.txt",
|
subject_template="account/email/password_reset_key_subject.txt",
|
||||||
text_template="members/email/claim_approved.txt",
|
text_template="account/email/password_reset_key_message.txt",
|
||||||
html_template="members/email/claim_approved.html",
|
html_template="account/email/password_reset_key_message.html",
|
||||||
build_context=_claim_approved_context,
|
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:
|
def render_preview(preview: EmailPreview, *, club, request) -> dict:
|
||||||
context = preview.build_context(club, request)
|
context = preview.build_context(club, request)
|
||||||
|
|||||||
87
management/pdf_previews.py
Normal file
87
management/pdf_previews.py
Normal 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))
|
||||||
34
management/templates/management/_document_preview_card.html
Normal file
34
management/templates/management/_document_preview_card.html
Normal 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>
|
||||||
@@ -93,7 +93,6 @@
|
|||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
{% if is_club_admin %}
|
{% 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 == '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 %}
|
{% endif %}
|
||||||
<a class="nav-subitem {% if nav == 'onboarding_requirement_list' %}active{% endif %}" href="{% url 'management:onboarding_requirement_list' %}">{% trans "Onboarding requirements" %}</a>
|
<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 %}
|
{% if is_club_admin %}
|
||||||
|
|||||||
@@ -9,26 +9,37 @@
|
|||||||
closely for the Colours card (merged swatch+hex row, side-by-side
|
closely for the Colours card (merged swatch+hex row, side-by-side
|
||||||
contrast-check boxes) and the Logo card (dashed drop zone) -- markup
|
contrast-check boxes) and the Logo card (dashed drop zone) -- markup
|
||||||
pulled straight from the design canvas (RosterChief Platform.dc.html),
|
pulled straight from the design canvas (RosterChief Platform.dc.html),
|
||||||
not the prose summary. The live preview has two tabs rather than D9's
|
not the prose summary.
|
||||||
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
|
Three top-level tabs, not just a preview card: Identity (the settings
|
||||||
is the same colours applied ahead of it existing); Email is real and
|
form, plus a small live-updating App & Website mock -- colours from the
|
||||||
functional, a generic sample shaped like the actual branded emails this
|
unsaved form inputs, via the script below) is D9 as designed. Email and
|
||||||
app sends (club/templates/club/email/*.html, members/.../claim_approved.html),
|
PDF are real, not mocks -- every branded email (management/email_previews.py)
|
||||||
not a preview of any one specific message. The colour math (content
|
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
|
colour, contrast ratio) mirrors Club._content_color_for exactly, so
|
||||||
what's shown is the real number the server computes on save, not an
|
what's shown on the Identity tab is the real number the server computes
|
||||||
approximation.
|
on save, not an approximation.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% block panel_title %}{% trans "Club identity" %}{% endblock panel_title %}
|
{% block panel_title %}{% trans "Club identity" %}{% endblock panel_title %}
|
||||||
{% block heading %}{% trans "Club identity" %}{% endblock heading %}
|
{% block heading %}{% trans "Club identity" %}{% endblock heading %}
|
||||||
|
|
||||||
{% block actions %}
|
{% 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 %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div data-page-panel="identity">
|
||||||
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[1.2fr_1fr]">
|
<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">
|
<form id="club-settings-form" method="post" enctype="multipart/form-data" class="flex flex-col gap-5">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -117,16 +128,9 @@
|
|||||||
<div class="xl:sticky xl:top-4 xl:self-start">
|
<div class="xl:sticky xl:top-4 xl:self-start">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div class="card-title">{% lucide "eye" size=16 %} {% trans "Live preview" %}</div>
|
<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 class="mt-3 flex flex-wrap items-start gap-4">
|
||||||
<div id="preview-panel-app" class="flex flex-wrap items-start gap-4" data-preview-panel="app">
|
|
||||||
<div class="preview-phone-lg">
|
<div class="preview-phone-lg">
|
||||||
<div id="preview-phone-header" class="preview-phone-header">
|
<div id="preview-phone-header" class="preview-phone-header">
|
||||||
<div class="preview-phone-header-row">
|
<div class="preview-phone-header-row">
|
||||||
@@ -173,29 +177,30 @@
|
|||||||
<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-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-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-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>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="preview-panel-email" class="preview-email hidden" data-preview-panel="email">
|
<div class="hidden flex-col gap-4" data-page-panel="email">
|
||||||
<div class="preview-email-header">
|
{% for preview in email_previews %}
|
||||||
<span id="preview-email-crest" class="preview-crest preview-crest-sm preview-email-crest">{{ club.initials|default:"CL" }}</span>
|
{% url 'management:email_preview_render' preview.key as render_url %}
|
||||||
<span class="preview-email-name">{{ club.name|default:_("Your club") }}</span>
|
{% include "management/_document_preview_card.html" with preview=preview render_url=render_url show_text_toggle=True %}
|
||||||
</div>
|
{% endfor %}
|
||||||
<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="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>
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|
||||||
@@ -257,15 +262,6 @@
|
|||||||
document.getElementById("preview-website-join").style.background = secondary;
|
document.getElementById("preview-website-join").style.background = secondary;
|
||||||
document.getElementById("preview-website-join").style.color = secondaryContent;
|
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) => {
|
["preview-dot-1", "preview-dot-2", "preview-dot-3", "preview-dot-4"].forEach((id) => {
|
||||||
document.getElementById(id).style.background = secondary;
|
document.getElementById(id).style.background = secondary;
|
||||||
});
|
});
|
||||||
@@ -294,13 +290,29 @@
|
|||||||
|
|
||||||
updatePreview();
|
updatePreview();
|
||||||
|
|
||||||
// Live preview tabs -- toggles which .preview-panel is visible, App &
|
// Top-level page tabs -- Identity/Email/PDF. "Save changes" (the
|
||||||
// Website vs Email. Both panels stay in the DOM (and colour-bound) the
|
// topbar action) only applies to the Identity tab's own form, so it
|
||||||
// whole time; only which one is hidden changes.
|
// hides on the other two rather than sitting there doing nothing.
|
||||||
document.querySelectorAll("[data-preview-tab]").forEach((tab) => {
|
document.querySelectorAll("[data-page-tab]").forEach((tab) => {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
document.querySelectorAll("[data-preview-tab]").forEach((other) => other.classList.toggle("preview-tab-active", other === tab));
|
document.querySelectorAll("[data-page-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-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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -28,6 +28,7 @@ from events.services.recurrence import detach_occurrence, generate_occurrences
|
|||||||
from management.bulk_import import TEMPLATE_COLUMNS
|
from management.bulk_import import TEMPLATE_COLUMNS
|
||||||
from management.email_previews import EMAIL_PREVIEWS
|
from management.email_previews import EMAIL_PREVIEWS
|
||||||
from management.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf
|
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 management.recurrence_ui import build_rrule, describe_rrule, parse_rrule
|
||||||
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
|
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
|
||||||
from members.services.claims import children_awaiting_a_parent
|
from members.services.claims import children_awaiting_a_parent
|
||||||
@@ -4717,12 +4718,15 @@ class SponsorManagementTests(ManagementTestBase):
|
|||||||
|
|
||||||
class ClubSettingsPreviewTests(ManagementTestBase):
|
class ClubSettingsPreviewTests(ManagementTestBase):
|
||||||
"""The D9-alike live preview on the Club identity page -- see
|
"""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):
|
def setUp(self):
|
||||||
self.client.force_login(self.admin_user)
|
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")
|
response = self.club_get("club_settings")
|
||||||
|
|
||||||
self.assertContains(response, "preview-tab-active")
|
self.assertContains(response, "preview-tab-active")
|
||||||
@@ -4740,18 +4744,12 @@ class ClubSettingsPreviewTests(ManagementTestBase):
|
|||||||
response = self.club_get("club_settings")
|
response = self.club_get("club_settings")
|
||||||
self.assertContains(response, 'name="website"')
|
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")
|
response = self.club_get("club_settings")
|
||||||
|
|
||||||
self.assertContains(response, "App & Website")
|
self.assertContains(response, 'data-page-tab="identity"')
|
||||||
self.assertContains(response, 'data-preview-tab="app"')
|
self.assertContains(response, 'data-page-tab="email"')
|
||||||
self.assertNotContains(response, 'data-preview-tab="website"')
|
self.assertContains(response, 'data-page-tab="pdf"')
|
||||||
|
|
||||||
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"')
|
|
||||||
|
|
||||||
def test_saving_still_updates_the_club(self):
|
def test_saving_still_updates_the_club(self):
|
||||||
response = self.club_post(
|
response = self.club_post(
|
||||||
@@ -4766,60 +4764,79 @@ class ClubSettingsPreviewTests(ManagementTestBase):
|
|||||||
self.assertEqual(self.club.website, "https://ajax-united.example")
|
self.assertEqual(self.club.website, "https://ajax-united.example")
|
||||||
|
|
||||||
|
|
||||||
class EmailPreviewListViewTests(ManagementTestBase):
|
class ClubSettingsDocumentTabTests(ManagementTestBase):
|
||||||
"""Settings > Email previews -- see management.email_previews."""
|
"""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):
|
def setUp(self):
|
||||||
self.client.force_login(self.admin_user)
|
self.client.force_login(self.admin_user)
|
||||||
|
|
||||||
def test_renders_every_registered_preview(self):
|
def test_renders_every_registered_email_and_pdf_preview(self):
|
||||||
response = self.club_get("email_preview_list")
|
response = self.club_get("club_settings")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
for preview in EMAIL_PREVIEWS:
|
for preview in EMAIL_PREVIEWS:
|
||||||
self.assertContains(response, preview.label)
|
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):
|
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:
|
for preview in EMAIL_PREVIEWS:
|
||||||
self.assertContains(response, reverse("management:email_preview_render", args=[preview.key]))
|
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):
|
def test_the_subject_and_plain_text_body_render_for_emails(self):
|
||||||
response = self.club_get("email_preview_list")
|
response = self.club_get("club_settings")
|
||||||
|
|
||||||
self.assertContains(response, "Subject:")
|
self.assertContains(response, "Subject:")
|
||||||
self.assertContains(response, "DUE-2026-00042")
|
self.assertContains(response, "DUE-2026-00042")
|
||||||
|
|
||||||
def test_no_real_invoice_or_claim_data_is_needed(self):
|
def test_pdf_cards_have_no_plain_text_toggle(self):
|
||||||
# The whole point: a preview renders with zero DuesInvoice/ParentClaim
|
# PDFs have no plain-text body to switch to -- unlike an email card,
|
||||||
# rows in the database.
|
# a PDF card is just the one iframe. Exactly one toggle button per
|
||||||
self.assertFalse(DuesInvoice.objects.exists())
|
# 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)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_non_admin_gets_403(self):
|
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")
|
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")
|
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)
|
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)
|
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
|
||||||
self.client.force_login(coach_user)
|
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)
|
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):
|
class EmailPreviewRenderViewTests(ManagementTestBase):
|
||||||
"""The actual per-email HTML document each card's iframe loads via `src` --
|
"""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."""
|
sub-request rather than a srcdoc="..." attribute."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -4843,10 +4860,16 @@ class EmailPreviewRenderViewTests(ManagementTestBase):
|
|||||||
self.assertContains(response, "Ajax United")
|
self.assertContains(response, "Ajax United")
|
||||||
self.assertContains(response, "#123456")
|
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):
|
def test_allows_same_origin_framing(self):
|
||||||
# The site-wide default (SecurityMiddleware, no X_FRAME_OPTIONS override
|
# The site-wide default (SecurityMiddleware, no X_FRAME_OPTIONS override
|
||||||
# in settings.py) is DENY -- this response overrides that specifically,
|
# 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")
|
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")
|
self.assertEqual(response["X-Frame-Options"], "SAMEORIGIN")
|
||||||
@@ -4869,6 +4892,51 @@ class EmailPreviewRenderViewTests(ManagementTestBase):
|
|||||||
self.assertEqual(response.status_code, 403)
|
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):
|
class BillingEndingBannerTests(ManagementTestBase):
|
||||||
"""The club dashboard's "billing is about to stop" warning -- see
|
"""The club dashboard's "billing is about to stop" warning -- see
|
||||||
management.views.HomeView and management/templates/management/home.html.
|
management.views.HomeView and management/templates/management/home.html.
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ urlpatterns = [
|
|||||||
path("forms/<uuid:pk>/submissions/", views.SubmissionListView.as_view(), name="submission_list"),
|
path("forms/<uuid:pk>/submissions/", views.SubmissionListView.as_view(), name="submission_list"),
|
||||||
# Settings (admin only)
|
# Settings (admin only)
|
||||||
path("settings/", views.ClubSettingsView.as_view(), name="club_settings"),
|
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/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/", 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/new/", views.OnboardingRequirementCreateView.as_view(), name="onboarding_requirement_create"),
|
||||||
path("settings/onboarding-requirements/<uuid:pk>/edit/", views.OnboardingRequirementUpdateView.as_view(), name="onboarding_requirement_update"),
|
path("settings/onboarding-requirements/<uuid:pk>/edit/", views.OnboardingRequirementUpdateView.as_view(), name="onboarding_requirement_update"),
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment
|
|||||||
from teams.services import eligible_roster_members
|
from teams.services import eligible_roster_members
|
||||||
|
|
||||||
from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook
|
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 (
|
from .forms import (
|
||||||
AddChildForm,
|
AddChildForm,
|
||||||
AddParentForm,
|
AddParentForm,
|
||||||
@@ -96,6 +96,7 @@ from .forms import (
|
|||||||
bulk_add_member_label,
|
bulk_add_member_label,
|
||||||
)
|
)
|
||||||
from .pdf import PDFExportError, event_referee_form_pdf, membership_list_pdf, referee_form_colors
|
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
|
from .recurrence_ui import describe_rrule
|
||||||
|
|
||||||
|
|
||||||
@@ -3319,49 +3320,35 @@ class ClubSettingsView(ClubAdminRequiredMixin, UpdateView):
|
|||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse("management:club_settings")
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
club = self.request.club
|
club = self.request.club
|
||||||
previews = []
|
email_previews = []
|
||||||
for preview in EMAIL_PREVIEWS:
|
for preview in EMAIL_PREVIEWS:
|
||||||
rendered = render_preview(preview, club=club, request=self.request)
|
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"]})
|
email_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)
|
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")
|
@method_decorator(xframe_options_sameorigin, name="get")
|
||||||
class EmailPreviewRenderView(ClubAdminRequiredMixin, View):
|
class EmailPreviewRenderView(ClubAdminRequiredMixin, View):
|
||||||
"""The actual HTML document for one preview, served at its own URL so
|
"""The actual HTML document for one email preview, served at its own URL
|
||||||
EmailPreviewListView's iframe can `src` it directly rather than smuggling
|
so the Email tab's iframe can `src` it directly rather than smuggling it
|
||||||
it through a srcdoc="..." attribute -- see that view's docstring for why.
|
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
|
xframe_options_sameorigin overrides the site-wide X-Frame-Options: DENY
|
||||||
(settings.py has no X_FRAME_OPTIONS override, so SecurityMiddleware's
|
(settings.py has no X_FRAME_OPTIONS override, so SecurityMiddleware's
|
||||||
default applies everywhere else): this response only ever needs to be
|
default applies everywhere else): this response only ever needs to be
|
||||||
framed by the page that links to it, on the same origin."""
|
framed by the page that links to it, on the same origin."""
|
||||||
|
|
||||||
def get(self, request, key):
|
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:
|
if preview is None:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
@@ -3369,6 +3356,22 @@ class EmailPreviewRenderView(ClubAdminRequiredMixin, View):
|
|||||||
return HttpResponse(rendered["html"], content_type="text/html; charset=utf-8")
|
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):
|
class OnboardingRequirementListView(MemberAdminRequiredMixin, ListView):
|
||||||
"""What a club requires from every member after they sign up or renew (a
|
"""What a club requires from every member after they sign up or renew (a
|
||||||
photo, a medical certificate, ...) -- see club/models.py's OnboardingRequirement
|
photo, a medical certificate, ...) -- see club/models.py's OnboardingRequirement
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user