Document the parent-claim, families, and email work in ARCHITECTURE.md

Covers submitted_by_user/family-merge semantics, season-scoped claim
history, the member/guardian/both filter and Families list, sidebar
counters, list pagination, and the HTML email templates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:56:13 +02:00
parent fbefdf3680
commit 8e64b37d53

View File

@@ -466,6 +466,26 @@ ClubMembership(ClubScopedModel) # -> carries `club`
cost the parent their place in the queue; the admin sees a distinct warning message
(`management/views.py::ParentClaimApproveView`) telling them the email didn't go and to have
the parent use "Forgot your password?" instead.
- **A signed-in parent claiming a second child links, never duplicates.**
`ParentClaim.submitted_by_user` (nullable FK to `authentication.User`) records who was
actually logged in when a claim was submitted — kept separate from the free-text
`parent_email`/name fields, which stay exactly as typed as the admin's audit trail even when
they drift from the account. When set, `members/views.py::ParentClaimView` drops the parent
fields from the form entirely (shown as read-only "submitting as…" text, not just pre-filled
inputs — a disabled input doesn't even submit, and a pre-filled-but-editable one still lets a
typo through) and populates them server-side from the `Member` instead of trusting POST data.
On approval, `members/services/claims.py::approve_claim` resolves the parent via that FK
(never re-derived from email) and, if they already belong to a `Family`, moves the newly
claimed child's `FamilyMembership` onto *that* family rather than leaving them split across
two households — the child's now-empty solo family is cleaned up the same way
`members/services/family.py::detach_from_family` already does elsewhere. No existing family →
falls back to the original behaviour of joining the child's own family. This is the one place
a parent, once they exist, is the anchor for "one household," not the child.
- **The "already dealt with" history is scoped to the current season.** Last season's approved
and rejected claims are real history but not something a club needs staring at year after
year, so `management/views.py::ParentClaimListView` filters `reviewed` to
`reviewed_at__date__gte=` the current season's start (empty, not erroring, when there's no
current season) rather than dropping the section outright.
- **Why a field and not a separate model.** Everything that answers "is this person attached
to this club" already reads through `ClubMembership` — tenancy scoping, group membership,
the club-wide event audience — and a second kind of link would need a parallel path through
@@ -483,6 +503,26 @@ ClubMembership(ClubScopedModel) # -> carries `club`
weed guardians out would take those people with it. Pass `include_guardians=True` where the
page is about a *person* rather than about members — a guardian's own detail page, editing
them, the group pickers, or a family page (whose parents are the whole point).
- **The members list makes the member/guardian split visible instead of silent.**
`management/views.py::MemberListView` reads `?kind=member|guardian|both` (default `member`,
unchanged from before this filter existed) — without it, a guardian was simply absent from
the page with nothing explaining why, which read as a bug rather than the intended "guardians
aren't members" design. `management/views.py::FamilyListView` (`management:family_list`) is
the complementary household-first view: one row per family, its parents/guardians in one
column and its children in another, with the family name and a separate "Edit" action both
landing on `family_detail` — matching the "Edit goes to the overview, not a bare rename form"
convention the Groups list already established.
- **List views are paginated** (`MemberListView`, `EventListView`, `TeamListView`,
`GroupListView`, `NewsListView`, `FamilyListView`; `paginate_by = 25`) through one shared
`management/templates/management/_pagination.html` partial, using Django's `{% querystring %}`
tag so a page link preserves whatever `?q=`/`?kind=`/etc. filter is active rather than
resetting it.
- **The sidebar surfaces two counts that would otherwise require opening the page to see**:
pending parent claims, and games in the next 10 with no referee assigned (the latter shares
its query with `RefereeManagementDashboardView`'s own KPI, factored out so the two can't drift
apart). Both are admin-gated the same way the rest of that nav section is, and both render
`0` explicitly rather than hiding the badge — the point is to make "nothing outstanding" just
as visible as "something needs attention," not to declutter the zero case away.
- One row per member **per season** — sign-up and fee payment are tracked independently
each season. `unique_together` moves from `(club, member)``(club, member, season)`
(a data migration must backfill existing rows with the current season).
@@ -1244,7 +1284,27 @@ Two ways to use **Resend** (resend.com), no third-party SDK either way:
`RESEND_API_KEY=…`) posts each message straight to Resend's `/emails` endpoint via `requests`
(already a dependency, so no new one needed for this). Handles plain text, the HTML
alternative on an `EmailMultiAlternatives`, cc/bcc/reply-to, and base64-encoded attachments;
`fail_silently` is honoured the same way Django's own backends honour it.
`fail_silently` is honoured the same way Django's own backends honour it. Confirmed against
Resend's own docs: `POST /emails` accepts `html` alongside `text` (and will derive `text` from
`html` if only the former is given), so this backend needed no changes to carry the HTML
templates below.
- **HTML email templates** *(built)*. `templates/email/_base.html` is the one shared shell
(inline styles only, ~600px single-column table — email clients strip `<style>` blocks and
ignore linked stylesheets, so daisyUI/Tailwind's compiled CSS is irrelevant here) with a
`{% include "email/_button.html" %}` for a styled CTA link. Every HTML email extends it and
supplies a plain-text sibling — `members/services/claims.py::send_claim_approved_email` builds
an `EmailMultiAlternatives` from `claim_approved.txt` + `claim_approved.html`
(`.attach_alternative(html, "text/html")`), the same never-fail-the-caller contract as before
(`except OSError: return False`). `club/templatetags/club_email.py::absolute_media_url` turns
`Club.logo` (a relative `FileSystemStorage` URL) into something an inbox can actually fetch,
by resolving it against the `request` carried in the template context.
- **allauth's password-reset email is overridden**, not left at its default plain text —
`templates/account/email/password_reset_key_message.html` sits next to allauth's own
`.txt` at the path its `render_mail` already looks for (no Python-side override needed;
allauth auto-attaches a same-named `.html` as the HTML alternative). Branches on whether a
`club` is in context (a reset requested from a club subdomain gets that club's branding) and
falls back to a plain RosterChief wordmark otherwise (the platform-level login has no club to
brand it with).
---