Add a Settings > Email previews page showing every branded email with sample data
Renders the real templates every send function uses (claim approved, membership invoice, invoice reminder) against hand-built placeholder context -- no real Member/ClubMembership/DuesInvoice row needed, so nothing here can leak real data. Each card shows the subject line and toggles between the HTML render (in an iframe, so the email's own markup can't clash with the page's) and the plain-text body, using the same bg-steel pill-toggle pattern as the news preview's NL/EN switch (generalised to .view-toggle-btn, kept .news-lang-btn as an alias). Admin-only, alongside Club identity in Settings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -1679,15 +1679,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- News preview (management/templates/management/_news_preview.html) ---
|
/* --- In-page content-swap toggle (bg-steel pill shell) ---
|
||||||
The NL/EN language toggle above the article body -- same bg-steel pill
|
Same bg-steel pill shell as member_list.html's Members/Guardians/Both
|
||||||
shell as member_list.html's Members/Guardians/Both selector, but a JS-
|
selector, but a JS-toggled pair (or more) of <button>s -- an in-page
|
||||||
toggled pair of <button>s (an in-page content swap, not a page navigation
|
content swap, not a page navigation with its own querystring state -- so a
|
||||||
with its own querystring state) rather than links, so a dedicated small
|
dedicated small class pair is simpler than juggling several Tailwind
|
||||||
class pair is simpler than juggling several Tailwind utilities via
|
utilities via classList from the script.
|
||||||
classList from the script. */
|
|
||||||
|
.view-toggle-btn is the generic name (email_previews.html's Email/Plain
|
||||||
|
text toggle); .news-lang-btn is kept as an alias so _news_preview.html's
|
||||||
|
NL/EN toggle (and its own JS, which reads that exact class) needs no
|
||||||
|
change. */
|
||||||
@layer components {
|
@layer components {
|
||||||
.news-lang-btn {
|
.news-lang-btn,
|
||||||
|
.view-toggle-btn {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
@@ -1700,11 +1705,13 @@
|
|||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-lang-btn:hover {
|
.news-lang-btn:hover,
|
||||||
|
.view-toggle-btn:hover {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-lang-btn.active {
|
.news-lang-btn.active,
|
||||||
|
.view-toggle-btn.active {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ _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",
|
||||||
@@ -172,6 +173,7 @@ _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",
|
||||||
|
|||||||
92
management/email_previews.py
Normal file
92
management/email_previews.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Sample renders of every branded email this app can send -- the Settings >
|
||||||
|
Email previews page (management/templates/management/email_previews.html),
|
||||||
|
so a club can see exactly what a member/parent receives without anything
|
||||||
|
actually being sent.
|
||||||
|
|
||||||
|
Each entry renders the *real* templates the real send functions use (see
|
||||||
|
members.services.claims.send_claim_approved_email and
|
||||||
|
club.services.invoicing.send_invoice_email/send_reminder_email) against a
|
||||||
|
hand-built sample context -- never a real Member/ClubMembership/DuesInvoice
|
||||||
|
row, so this needs nothing from the database beyond the current club itself,
|
||||||
|
and can't leak anything real. Adding a new branded email later means adding
|
||||||
|
one entry here, not touching the view or template.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmailPreview:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
subject_template: str
|
||||||
|
text_template: str
|
||||||
|
html_template: str
|
||||||
|
build_context: Callable[..., dict]
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_approved_context(club, request):
|
||||||
|
return {
|
||||||
|
"club": club,
|
||||||
|
"child": "Jamie Doe",
|
||||||
|
"parent_first_name": "Alex",
|
||||||
|
"set_password_url": f"https://{club.slug}.rosterchief.app/accounts/password/reset/key/example/",
|
||||||
|
"request": request,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dues_invoice_context(club, request, *, overdue):
|
||||||
|
today = timezone.now().date()
|
||||||
|
due_date = today - datetime.timedelta(days=5) if overdue else today + datetime.timedelta(days=14)
|
||||||
|
invoice = SimpleNamespace(number="DUE-2026-00042", amount=Decimal("45.00"), due_date=due_date)
|
||||||
|
membership = SimpleNamespace(season="2026-2027")
|
||||||
|
member = SimpleNamespace(first_name="Jamie")
|
||||||
|
return {"club": club, "invoice": invoice, "membership": membership, "member": member, "request": request}
|
||||||
|
|
||||||
|
|
||||||
|
EMAIL_PREVIEWS = [
|
||||||
|
EmailPreview(
|
||||||
|
key="dues_invoice",
|
||||||
|
label=_("Membership invoice"),
|
||||||
|
description=_("Sent when a staff member clicks “Send invoice” for one or more members on the Dues & billing page."),
|
||||||
|
subject_template="club/email/dues_invoice_subject.txt",
|
||||||
|
text_template="club/email/dues_invoice.txt",
|
||||||
|
html_template="club/email/dues_invoice.html",
|
||||||
|
build_context=lambda club, request: _dues_invoice_context(club, request, overdue=False),
|
||||||
|
),
|
||||||
|
EmailPreview(
|
||||||
|
key="dues_invoice_reminder",
|
||||||
|
label=_("Invoice reminder"),
|
||||||
|
description=_("Sent by the “Send reminders” button, once per invoice that's still unpaid past its due date."),
|
||||||
|
subject_template="club/email/dues_invoice_reminder_subject.txt",
|
||||||
|
text_template="club/email/dues_invoice_reminder.txt",
|
||||||
|
html_template="club/email/dues_invoice_reminder.html",
|
||||||
|
build_context=lambda club, request: _dues_invoice_context(club, request, overdue=True),
|
||||||
|
),
|
||||||
|
EmailPreview(
|
||||||
|
key="claim_approved",
|
||||||
|
label=_("Parent claim approved"),
|
||||||
|
description=_("Sent when a staff member approves a parent or guardian's claim to a child, from the Parent claims page."),
|
||||||
|
subject_template="members/email/claim_approved_subject.txt",
|
||||||
|
text_template="members/email/claim_approved.txt",
|
||||||
|
html_template="members/email/claim_approved.html",
|
||||||
|
build_context=_claim_approved_context,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_preview(preview: EmailPreview, *, club, request) -> dict:
|
||||||
|
context = preview.build_context(club, request)
|
||||||
|
subject = " ".join(render_to_string(preview.subject_template, context).split())
|
||||||
|
text_body = render_to_string(preview.text_template, context).strip()
|
||||||
|
html_body = render_to_string(preview.html_template, context)
|
||||||
|
return {"subject": subject, "text": text_body, "html": html_body}
|
||||||
@@ -93,6 +93,7 @@
|
|||||||
<div class="flex flex-col">
|
<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 %}
|
||||||
|
|||||||
43
management/templates/management/email_previews.html
Normal file
43
management/templates/management/email_previews.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Email previews" %}{% endblock heading %}
|
||||||
|
{% block topbar_context %}<span class="text-sm text-muted">{% trans "Exactly what a member or parent receives -- with sample data, nothing is sent." %}</span>{% endblock topbar_context %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
{% for preview in previews %}
|
||||||
|
<div class="card overflow-hidden" data-email-card>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-line px-4.5 py-3.5">
|
||||||
|
<div>
|
||||||
|
<div class="font-display text-sm font-extrabold tracking-wide text-ink uppercase">{{ preview.label }}</div>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-muted">{{ preview.description }}</p>
|
||||||
|
<div class="mt-2 font-mono text-xs text-muted"><span class="text-dim">{% trans "Subject:" %}</span> {{ preview.subject }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 rounded-full bg-steel p-1">
|
||||||
|
<button type="button" class="view-toggle-btn active" data-view-btn="html">{% trans "Email" %}</button>
|
||||||
|
<button type="button" class="view-toggle-btn" data-view-btn="text">{% trans "Plain text" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-subhead p-4" data-view-panel="html">
|
||||||
|
<iframe class="h-[560px] w-full rounded-lg border border-line bg-white" srcdoc="{{ preview.html }}" title="{{ preview.label }}"></iframe>
|
||||||
|
</div>
|
||||||
|
<pre class="hidden overflow-x-auto p-4.5 font-mono text-sm whitespace-pre-wrap text-ink" data-view-panel="text">{{ preview.text }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
document.querySelectorAll("[data-email-card]").forEach((card) => {
|
||||||
|
card.querySelectorAll("[data-view-btn]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
card.querySelectorAll("[data-view-btn]").forEach((b) => b.classList.toggle("active", b === button));
|
||||||
|
card.querySelectorAll("[data-view-panel]").forEach((el) => el.classList.toggle("hidden", el.dataset.viewPanel !== button.dataset.viewBtn));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock extra_body %}
|
||||||
@@ -26,6 +26,7 @@ from events.models import Attendance, Competition, Event, EventReferee, EventSer
|
|||||||
from events.services.rbihf_import import RBIHFImportError
|
from events.services.rbihf_import import RBIHFImportError
|
||||||
from events.services.recurrence import detach_occurrence, generate_occurrences
|
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.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.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
|
||||||
@@ -4765,6 +4766,61 @@ class ClubSettingsPreviewTests(ManagementTestBase):
|
|||||||
self.assertEqual(self.club.website, "https://ajax-united.example")
|
self.assertEqual(self.club.website, "https://ajax-united.example")
|
||||||
|
|
||||||
|
|
||||||
|
class EmailPreviewListViewTests(ManagementTestBase):
|
||||||
|
"""Settings > Email previews -- see management.email_previews."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client.force_login(self.admin_user)
|
||||||
|
|
||||||
|
def test_renders_every_registered_preview(self):
|
||||||
|
response = self.club_get("email_preview_list")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
for preview in EMAIL_PREVIEWS:
|
||||||
|
self.assertContains(response, preview.label)
|
||||||
|
|
||||||
|
def test_the_html_render_uses_the_clubs_own_branding(self):
|
||||||
|
self.club.name = "Ajax United"
|
||||||
|
self.club.secondary_color = "#123456"
|
||||||
|
self.club.save(update_fields=["name", "secondary_color"])
|
||||||
|
|
||||||
|
response = self.club_get("email_preview_list")
|
||||||
|
|
||||||
|
self.assertContains(response, "Ajax United")
|
||||||
|
self.assertContains(response, "#123456")
|
||||||
|
|
||||||
|
def test_the_subject_and_plain_text_body_render(self):
|
||||||
|
response = self.club_get("email_preview_list")
|
||||||
|
|
||||||
|
self.assertContains(response, "Subject:")
|
||||||
|
self.assertContains(response, "DUE-2026-00042")
|
||||||
|
|
||||||
|
def test_no_real_invoice_or_claim_data_is_needed(self):
|
||||||
|
# The whole point: a preview renders with zero DuesInvoice/ParentClaim
|
||||||
|
# rows in the database.
|
||||||
|
self.assertFalse(DuesInvoice.objects.exists())
|
||||||
|
|
||||||
|
response = self.club_get("email_preview_list")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_non_admin_gets_403(self):
|
||||||
|
coach_user = User.objects.create_user(email="coach-email-preview@example.com", password="pw-secret-123")
|
||||||
|
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
|
||||||
|
team = Team.objects.create(club=self.club, name="U18", short_name="U18")
|
||||||
|
position = Position.objects.create(club=self.club, name="Coach14", short_name="C14", staff_position=True)
|
||||||
|
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
|
||||||
|
self.client.force_login(coach_user)
|
||||||
|
|
||||||
|
response = self.club_get("email_preview_list")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_nav_entry_is_admin_only(self):
|
||||||
|
admin_response = self.club_get("club_settings")
|
||||||
|
self.assertContains(admin_response, reverse("management:email_preview_list"))
|
||||||
|
|
||||||
|
|
||||||
class BillingEndingBannerTests(ManagementTestBase):
|
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,6 +135,7 @@ 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/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"),
|
||||||
|
|||||||
@@ -52,6 +52,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 .forms import (
|
from .forms import (
|
||||||
AddChildForm,
|
AddChildForm,
|
||||||
AddParentForm,
|
AddParentForm,
|
||||||
@@ -3320,6 +3321,21 @@ class ClubSettingsView(ClubAdminRequiredMixin, UpdateView):
|
|||||||
return super().get_context_data(**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."""
|
||||||
|
|
||||||
|
template_name = "management/email_previews.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
club = self.request.club
|
||||||
|
previews = [{"key": preview.key, "label": preview.label, "description": preview.description, **render_preview(preview, club=club, request=self.request)} for preview in EMAIL_PREVIEWS]
|
||||||
|
return super().get_context_data(previews=previews, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class OnboardingRequirementListView(MemberAdminRequiredMixin, ListView):
|
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
|
||||||
|
|||||||
@@ -3922,6 +3922,9 @@
|
|||||||
.h-\[190px\] {
|
.h-\[190px\] {
|
||||||
height: 190px;
|
height: 190px;
|
||||||
}
|
}
|
||||||
|
.h-\[560px\] {
|
||||||
|
height: 560px;
|
||||||
|
}
|
||||||
.h-fit {
|
.h-fit {
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
}
|
}
|
||||||
@@ -4942,6 +4945,9 @@
|
|||||||
.whitespace-pre-line {
|
.whitespace-pre-line {
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
.whitespace-pre-wrap {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
.alert-error {
|
.alert-error {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
color: var(--color-error-content);
|
color: var(--color-error-content);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user