Files
RosterChief/management/templates/management/member_detail.html
Bernard Siebens 744b623403 Separate guardians from members: a parent is not automatically a member
members/services/family.py enrolled a parent exactly like the child they were
registering, so every parent held a full membership: counted in the member
list, in the club and platform KPIs, and in the fee roll, with a fee record of
their own. ClubMembership.kind (member | guardian) separates the two.

A guardian is attached to the club only through their child. They hold the
login, can be contacted and can sit in a Group -- the stated exception -- but
they are not a member: no fee (clean() refuses one), absent from the member
list, the fee list and every member count, and not eligible for a roster or a
staff spot. A parent who also plays or coaches is a member who happens to be a
parent; the two facts are independent, which is why this is its own field
rather than inferred from FamilyMembership.role.

A field on ClubMembership rather than a separate model because everything that
answers "is this person attached to this club" already reads through that table
-- tenancy, groups, the club-wide event audience -- and a second kind of link
would need a parallel path through all of it. What changes is only who counts.

Two things that weren't obvious going in:

Excluding guardians had to be a subtraction, not a narrower filter. The obvious
move -- match only member-kind rows and drop the MEMBER-role branch, since an
active membership of any kind grants that role -- also hides someone the club
knows but hasn't signed up for a season yet, which is a real state the member
edit page supports. Two existing tests caught it. _guardians_only() subtracts
instead, so anyone who also plays, is on staff or runs the club stays visible.

Their tie to the club isn't seasonal but rides on a per-season row, so it has
to be carried forward or a parent silently drops off at the season boundary
while their child stays enrolled. Copied from the immediately preceding season
only, so a deliberate removal isn't resurrected from an older row.

The data migration reclassifies existing parents, deliberately skipping anyone
who plays, is on a team's staff or holds an elevated ClubRole -- demoting them
would strip them from their own team's roster eligibility. Anything ambiguous
stays a member, which an admin can flip; noticing someone quietly vanished is
much harder.

The import template gains a membership_kind column next to family_role (a
child marked guardian is refused), and the review screen shows what each row
will join as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:11:41 +02:00

269 lines
17 KiB
HTML

{% extends "management/base.html" %}
{% load lucide ui i18n static %}
{% block heading %}{{ member.get_full_name }}{% endblock heading %}
{% block subheading %}{{ member.contact_email }}{% endblock subheading %}
{% block actions %}
{% if is_club_admin %}
<a class="btn btn-outline gap-2" href="{% url 'management:member_update' member.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user" size=18 %} {% trans "Personal information" %}</h2>
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Date of birth" %}</dt>
<dd class="font-semibold">{{ member.date_of_birth|default:"-" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Phone" %}</dt>
<dd class="font-semibold">{{ member.phone.as_international|default:"-" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Emergency phone" %}</dt>
<dd class="font-semibold">{{ member.emergency_phone.as_international|default:"-" }}</dd>
</div>
{% for guardian in guardians %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Parent phone" %} — {{ guardian.get_full_name }}</dt>
<dd class="font-semibold">{{ guardian.phone.as_international|default:"-" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Parent emergency phone" %} — {{ guardian.get_full_name }}</dt>
<dd class="font-semibold">{{ guardian.emergency_phone.as_international|default:"-" }}</dd>
</div>
{% endfor %}
</dl>
<div class="flex flex-row gap-2 items-center mt-2">
{% if member.phone %}
<a class="btn btn-sm btn-outline btn-primary grow" href="tel:{{ member.phone.as_international }}">{% lucide "phone" size=14 %} {% trans "Call" %}</a>
{% endif %}
{% if member.emergency_phone %}
<a class="btn btn-sm btn-outline btn-error grow" href="tel:{{ member.emergency_phone.as_international }}">{% lucide "shield-alert" size=14 %} {% trans "Emergency call" %}</a>
{% endif %}
</div>
{% if guardians %}
<div class="divider my-1"></div>
<div class="text-xs font-semibold uppercase opacity-60 flex items-center gap-1 mb-1">
{% lucide "users" size=12 %} {% trans "Parent/guardian contact" %}
</div>
{% for guardian in guardians %}
{% if guardian.phone or guardian.emergency_phone %}
<div class="flex flex-row gap-2 items-center mt-2">
{% if guardian.phone %}
<a class="btn btn-sm btn-outline btn-primary grow" href="tel:{{ guardian.phone.as_international }}">{% lucide "phone" size=14 %} {% blocktrans with name=guardian.get_full_name %}Call {{ name }}{% endblocktrans %}</a>
{% endif %}
{% if guardian.emergency_phone %}
<a class="btn btn-sm btn-outline btn-error grow" href="tel:{{ guardian.emergency_phone.as_international }}">{% lucide "shield-alert" size=14 %} {% blocktrans with name=guardian.get_full_name %}Emergency: {{ name }}{% endblocktrans %}</a>
{% endif %}
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
{% comment %}
A Member has no club of its own -- ClubMembership is the only thing that
actually ties this person to *this* club, so it gets its own panel: the
current season's standing, plus every season they've been part of.
{% endcomment %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "id-card" size=18 %} {% trans "Club membership" %}</h2>
{% if current_membership %}
{% if current_membership.is_guardian %}
{% comment %}
Stated plainly rather than left to be inferred from an empty
fee: a guardian is deliberately missing from the member list
and every member count, and someone looking at this page needs
to know that is on purpose. See club.models.ClubMembership.Kind.
{% endcomment %}
<div class="alert alert-warning py-2 my-1">
{% lucide "users" size=16 %}
<span class="text-sm">{% trans "Guardian: attached to the club as a parent of a member. Holds the login, owes no fee, and is not counted as a member." %}</span>
</div>
{% endif %}
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Joined as" %}</dt>
<dd class="font-semibold">{{ current_membership.get_kind_display|capfirst }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "License" %}</dt>
<dd class="font-semibold">{{ current_membership.license|default:"-" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Status" %}</dt>
<dd class="font-semibold">{{ current_membership.get_status_display }}</dd>
</div>
{% if not current_membership.is_guardian %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Fee status" %}</dt>
<dd class="font-semibold">{{ current_membership.get_fee_status_display }}</dd>
</div>
{% endif %}
</dl>
{% else %}
<p class="text-sm opacity-60">{% trans "Not rostered for the current season." %}</p>
{% endif %}
{% if membership_history %}
<div class="divider"></div>
<h3 class="text-sm font-semibold opacity-70">{% trans "Season history" %}</h3>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Season" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Fee status" %}</th>
<th>{% trans "License" %}</th>
</tr>
</thead>
<tbody>
{% for membership in membership_history %}
<tr>
<td>{{ membership.season.start_date|date:"Y" }} - {{ membership.season.end_date|date:"Y" }}</td>
<td>{{ membership.get_status_display }}</td>
<td>{{ membership.get_fee_status_display }}</td>
<td>{{ membership.license|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
{% if referee_profile or is_club_admin %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "flag" size=18 %} {% trans "Referee eligibility" %}</h2>
{% if is_club_admin %}
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('referee_eligibility_modal').showModal()">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
{% endif %}
</div>
{% if referee_profile and referee_profile.level %}
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Level" %}</dt>
<dd class="font-semibold">{{ referee_profile.level }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Valid until" %}</dt>
<dd class="font-semibold flex items-center gap-2">
{{ referee_profile.valid_until|default:"—" }}
{% if referee_profile.is_currently_valid %}
<span class="badge badge-success badge-sm">{% trans "Valid" %}</span>
{% elif referee_profile.valid_until %}
<span class="badge badge-error badge-sm">{% trans "Expired" %}</span>
{% else %}
<span class="badge badge-warning badge-sm">{% trans "No validity set" %}</span>
{% endif %}
</dd>
</div>
</dl>
{% if referee_profile.is_eligible %}
<div class="text-xs font-semibold uppercase opacity-60 mt-2 mb-1">{% trans "Can referee for" %}</div>
<div class="flex flex-wrap gap-2">
{% for team in referee_profile.eligible_teams %}
<span class="badge badge-outline">{{ team.name }}</span>
{% empty %}
<span class="text-sm opacity-60">{% trans "This level has no teams linked yet." %}</span>
{% endfor %}
</div>
{% else %}
<div class="alert alert-warning mt-2">
{% lucide "triangle-alert" size=16 %}
<span>{% trans "Not currently eligible to referee -- set or extend the validity date above to reinstate them." %}</span>
</div>
{% endif %}
{% else %}
<p class="text-sm opacity-60">{% trans "Not eligible to referee for any team." %}</p>
{% endif %}
</div>
</div>
{% if is_club_admin %}
{% url 'management:member_referee_eligibility_update' member.pk as referee_eligibility_url %}
{% trans "Referee eligibility" as referee_eligibility_title %}
{% trans "Save" as save_label %}
{% include "controlpanel/_modal_form.html" with modal_id="referee_eligibility_modal" title=referee_eligibility_title form=referee_eligibility_form action_url=referee_eligibility_url submit_label=save_label submit_icon="save" %}
{% endif %}
{% endif %}
{% trans "Add parent" as add_parent_label %}
{% trans "Add child" as add_child_label %}
{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
{% blocktrans with short_name=member.get_short_name asvar remove_from_family_label %}Remove {{ short_name }} from family{% endblocktrans %}
{% trans "Remove from family" as remove_from_family_title %}
{% trans "Remove" as remove_label %}
{% for family_group in family_groups %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">
{% lucide "users" size=18 %}
<a class="link link-hover" href="{% url 'management:family_detail' family_group.family.pk %}">{{ family_group.family }}</a>
</h2>
{% if is_club_admin %}
<div class="flex gap-2">
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_parent_modal" }}').showModal()">{% lucide "user-plus" size=14 %} {{ add_parent_label }}</button>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"add_child_modal" }}').showModal()">{% lucide "baby" size=14 %} {{ add_child_label }}</button>
<button class="btn btn-outline btn-error btn-sm gap-2" type="button" onclick="document.getElementById('{{ family_group.family.pk|dom_id:"detach_family_modal" }}').showModal()">{% lucide "user-x" size=14 %} {{ remove_from_family_label }}</button>
</div>
{% endif %}
</div>
{% include "management/_family_members_table.html" with group=family_group next_url=request.path %}
</div>
</div>
{% if is_club_admin %}
{% url 'management:family_add_parent' family_group.family.pk as add_parent_url %}
{% url 'management:family_add_child' family_group.family.pk as add_child_url %}
{% url 'management:member_detach_family' member.pk family_group.family.pk as detach_family_url %}
{% blocktrans with full_name=member.get_full_name family_name=family_group.family asvar detach_family_body %}Remove {{ full_name }} from {{ family_name }}? They become a standalone member -- nothing else about them changes.{% endblocktrans %}
{% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_parent_modal" title=add_parent_label form=add_parent_form action_url=add_parent_url submit_label=add_parent_label submit_icon="user-plus" blurb=add_parent_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id=family_group.family.pk|dom_id:"add_child_modal" title=add_child_label form=add_child_form action_url=add_child_url submit_label=add_child_label submit_icon="baby" %}
{% include "controlpanel/_confirm_modal.html" with modal_id=family_group.family.pk|dom_id:"detach_family_modal" title=remove_from_family_title body=detach_family_body action_url=detach_family_url submit_label=remove_label submit_icon="user-x" %}
{% endif %}
{% endfor %}
{% if is_club_admin %}
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "users" size=18 %} {% trans "Family" %}</h2>
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('attach_family_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add to family" %}</button>
</div>
{% if not family_groups %}
<p class="text-sm opacity-60">{% trans "Not part of a family." %}</p>
{% endif %}
</div>
</div>
{% url 'management:member_attach_family' member.pk as attach_family_url %}
{% trans "Add to family" as add_to_family_title %}
{% trans "Add" as add_label %}
{% trans "Pick an existing family, or leave it blank to start a new one." as attach_family_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="attach_family_modal" title=add_to_family_title form=attach_to_family_form action_url=attach_family_url submit_label=add_label submit_icon="user-plus" blurb=attach_family_blurb %}
{% endif %}
{% endblock panel %}
{% block extra_body %}
<script src="{% static 'js/searchable-select.js' %}"></script>
{% endblock extra_body %}