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>
This commit is contained in:
@@ -19,17 +19,23 @@ from members.models import FamilyMembership, Member
|
||||
|
||||
from .forms import MemberForm
|
||||
|
||||
TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status", "family_group", "family_role"]
|
||||
TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status", "family_group", "family_role", "membership_kind"]
|
||||
REQUIRED_HEADER_COLUMNS = {"first_name", "last_name"}
|
||||
|
||||
TEMPLATE_EXAMPLE_ROWS = [
|
||||
# A standalone member -- no family link.
|
||||
["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "", ""],
|
||||
["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "", "", ClubMembership.Kind.MEMBER],
|
||||
# A parent and child linked together: same family_group value, one row each.
|
||||
# The child has no email of its own -- it gets a login only if given one via
|
||||
# the "Grant login" action later, same as adding a family by hand.
|
||||
["Taylor", "Doe", "", "taylor.doe@example.com", "+32470654322", "", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "Doe family", FamilyMembership.FamilyRole.PARENT],
|
||||
["Jamie", "Doe", date(2014, 3, 2), "", "", "+32470654322", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "Doe family", FamilyMembership.FamilyRole.CHILD],
|
||||
#
|
||||
# membership_kind sits next to family_role because it answers the question
|
||||
# family_role raises: this parent is a `guardian`, so they hold the login and
|
||||
# can be contacted but don't count as a member and owe no fee. Put `member`
|
||||
# there instead for a parent who also plays -- the two are independent, which
|
||||
# is why it is its own column rather than inferred from family_role.
|
||||
["Taylor", "Doe", "", "taylor.doe@example.com", "+32470654322", "", "", ClubMembership.StatusChoices.ACTIVE, "", "Doe family", FamilyMembership.FamilyRole.PARENT, ClubMembership.Kind.GUARDIAN],
|
||||
["Jamie", "Doe", date(2014, 3, 2), "", "", "+32470654322", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "Doe family", FamilyMembership.FamilyRole.CHILD, ClubMembership.Kind.MEMBER],
|
||||
]
|
||||
|
||||
|
||||
@@ -48,7 +54,7 @@ def build_member_import_template():
|
||||
for example_row in TEMPLATE_EXAMPLE_ROWS:
|
||||
sheet.append(example_row)
|
||||
|
||||
for column_name, choices in (("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus), ("family_role", FamilyMembership.FamilyRole)):
|
||||
for column_name, choices in (("membership_kind", ClubMembership.Kind), ("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus), ("family_role", FamilyMembership.FamilyRole)):
|
||||
column_index = TEMPLATE_COLUMNS.index(column_name) + 1
|
||||
column_letter = sheet.cell(row=1, column=column_index).column_letter
|
||||
options = ",".join(choices.values)
|
||||
@@ -140,6 +146,12 @@ def parse_member_import_rows(rows, club):
|
||||
family_group, family_role, family_errors = _parse_family_fields(raw)
|
||||
errors.extend(family_errors)
|
||||
|
||||
# The two columns are otherwise independent -- a parent may or may not also
|
||||
# be a member -- but a child is the member the guardian is attached *to*,
|
||||
# so that particular combination is always a mistake.
|
||||
if family_role == FamilyMembership.FamilyRole.CHILD and membership_kwargs["kind"] == ClubMembership.Kind.GUARDIAN:
|
||||
errors.append(_("A child is always a member, so membership_kind cannot be 'guardian'."))
|
||||
|
||||
results.append(
|
||||
{
|
||||
"line_number": line_number,
|
||||
@@ -159,6 +171,16 @@ def _parse_membership_fields(raw):
|
||||
errors = []
|
||||
license_number = raw.get("license", "").strip()
|
||||
|
||||
kind = raw.get("membership_kind", "").strip()
|
||||
if kind:
|
||||
kind_value = _match_choice(kind, ClubMembership.Kind)
|
||||
if kind_value is None:
|
||||
errors.append(_("Invalid membership_kind '%(value)s'.") % {"value": kind})
|
||||
else:
|
||||
# Blank means member: the overwhelmingly common row, and the value every
|
||||
# file written before this column existed effectively carried.
|
||||
kind_value = ClubMembership.Kind.MEMBER
|
||||
|
||||
status = raw.get("status", "").strip()
|
||||
if status:
|
||||
status_value = _match_choice(status, ClubMembership.StatusChoices)
|
||||
@@ -175,7 +197,7 @@ def _parse_membership_fields(raw):
|
||||
else:
|
||||
fee_status_value = ClubMembership.FeeStatus.UNPAID
|
||||
|
||||
return {"license": license_number, "status": status_value, "fee_status": fee_status_value}, errors
|
||||
return {"kind": kind_value, "license": license_number, "status": status_value, "fee_status": fee_status_value}, errors
|
||||
|
||||
|
||||
def _parse_family_fields(raw):
|
||||
|
||||
@@ -707,6 +707,12 @@ class FamilyCreateForm(forms.Form):
|
||||
parent_last_name = forms.CharField(label=_("Parent last name"))
|
||||
parent_email = forms.EmailField(label=_("Parent email"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
|
||||
|
||||
parent_is_member = forms.BooleanField(
|
||||
label=_("Parent is also a member"),
|
||||
required=False,
|
||||
help_text=_("Tick only if this parent belongs to the club in their own right (they play, or hold a membership). Left unticked they are a guardian: they hold the login and can be contacted, but owe no fee and are not counted as a member."),
|
||||
)
|
||||
|
||||
child_first_name = forms.CharField(label=_("Child first name"))
|
||||
child_last_name = forms.CharField(label=_("Child last name"))
|
||||
child_date_of_birth = forms.DateField(label=_("Child date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))
|
||||
@@ -728,6 +734,11 @@ class AddParentForm(forms.Form):
|
||||
email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
|
||||
first_name = forms.CharField(label=_("First name"), required=False)
|
||||
last_name = forms.CharField(label=_("Last name"), required=False)
|
||||
parent_is_member = forms.BooleanField(
|
||||
label=_("Also a member"),
|
||||
required=False,
|
||||
help_text=_("Tick only if this parent belongs to the club in their own right (they play, or hold a membership). Left unticked they are a guardian: they hold the login and can be contacted, but owe no fee and are not counted as a member."),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
@@ -790,7 +801,7 @@ class ClubMembershipForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ClubMembership
|
||||
fields = ["license", "status", "fee_status", "fee_amount"]
|
||||
fields = ["kind", "license", "status", "fee_status", "fee_amount"]
|
||||
|
||||
|
||||
class NewsForm(forms.ModelForm):
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
{% form_field form.parent_first_name %}
|
||||
{% form_field form.parent_last_name %}
|
||||
{% form_field form.parent_email %}
|
||||
{% form_field form.parent_is_member %}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
@@ -79,7 +79,23 @@
|
||||
<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>
|
||||
@@ -88,10 +104,12 @@
|
||||
<dt class="text-sm opacity-70">{% trans "Status" %}</dt>
|
||||
<dd class="font-semibold">{{ current_membership.get_status_display }}</dd>
|
||||
</div>
|
||||
<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>
|
||||
{% 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>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<th>{% trans "First name" %}</th>
|
||||
<th>{% trans "Email" %}</th>
|
||||
<th>{% trans "Family" %}</th>
|
||||
<th>{% trans "Joining as" %}</th>
|
||||
<th>{% trans "Outcome" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -45,6 +46,18 @@
|
||||
<span class="opacity-40">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% comment %}
|
||||
A guardian holds the login for a child but is not a member:
|
||||
no fee, and not counted in any member total. See
|
||||
club.models.ClubMembership.Kind.
|
||||
{% endcomment %}
|
||||
{% if result.membership_kwargs.kind == "guardian" %}
|
||||
<span class="badge badge-warning badge-sm">{% trans "Guardian" %}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-neutral badge-sm">{% trans "Member" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if result.member %}
|
||||
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
|
||||
@@ -56,7 +69,7 @@
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
|
||||
<td colspan="7" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -246,7 +246,7 @@ class MemberManagementTests(ManagementTestBase):
|
||||
# one combined submit, so its (required) fields must come along.
|
||||
self.club_post(
|
||||
"member_update",
|
||||
{"first_name": "New", "last_name": "Name", "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.UNPAID},
|
||||
{"first_name": "New", "last_name": "Name", "kind": ClubMembership.Kind.MEMBER, "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.UNPAID},
|
||||
member.pk,
|
||||
)
|
||||
|
||||
@@ -1363,6 +1363,95 @@ class FamilyManagementTests(ManagementTestBase):
|
||||
self.assertEqual(family.guardians.count(), 1)
|
||||
|
||||
|
||||
class GuardianViewTests(ManagementTestBase):
|
||||
"""How a guardian -- a parent attached to the club only through their child --
|
||||
behaves across the management UI. See club.models.ClubMembership.Kind; the
|
||||
model-level guarantees live in club.tests.GuardianMembershipTests."""
|
||||
|
||||
def family_payload(self, **overrides):
|
||||
payload = {
|
||||
"parent_first_name": "Pat",
|
||||
"parent_last_name": "Parent",
|
||||
"parent_email": "pat.parent@example.com",
|
||||
"child_first_name": "Cody",
|
||||
"child_last_name": "Child",
|
||||
"child_date_of_birth": "2015-04-01",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
def test_registering_a_family_makes_the_parent_a_guardian_and_the_child_a_member(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("family_create", self.family_payload())
|
||||
|
||||
parent = Member.objects.get(first_name="Pat", last_name="Parent")
|
||||
child = Member.objects.get(first_name="Cody", last_name="Child")
|
||||
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
|
||||
self.assertEqual(ClubMembership.objects.get(club=self.club, member=child).kind, ClubMembership.Kind.MEMBER)
|
||||
|
||||
def test_ticking_also_a_member_enrols_the_parent_as_one(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("family_create", self.family_payload(parent_is_member="on"))
|
||||
|
||||
parent = Member.objects.get(first_name="Pat", last_name="Parent")
|
||||
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.MEMBER)
|
||||
|
||||
def test_a_guardian_is_absent_from_the_member_list(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
self.club_post("family_create", self.family_payload())
|
||||
|
||||
response = self.club_get("member_list")
|
||||
|
||||
self.assertContains(response, "Cody Child")
|
||||
self.assertNotContains(response, "Pat Parent")
|
||||
|
||||
def test_a_guardian_is_not_counted_in_the_membership_kpis(self):
|
||||
# Measured as a delta, not an absolute: the base fixture's admin is a
|
||||
# member too, and this is about what registering a family *adds*.
|
||||
self.client.force_login(self.admin_user)
|
||||
before = self.club_get("membership_list").context["kpi_total"]
|
||||
|
||||
self.club_post("family_create", self.family_payload())
|
||||
|
||||
after = self.club_get("membership_list").context["kpi_total"]
|
||||
# The child only. A guardian owes nothing, so counting them would
|
||||
# overstate the roll and everything derived from it.
|
||||
self.assertEqual(after - before, 1)
|
||||
|
||||
def test_a_guardians_own_page_is_still_reachable(self):
|
||||
# Excluded from the member *list*, not from the club: an admin has to be
|
||||
# able to open them, edit them, and switch them to a member.
|
||||
self.client.force_login(self.admin_user)
|
||||
self.club_post("family_create", self.family_payload())
|
||||
parent = Member.objects.get(first_name="Pat", last_name="Parent")
|
||||
|
||||
self.assertEqual(self.club_get("member_detail", parent.pk).status_code, 200)
|
||||
self.assertEqual(self.club_get("member_update", parent.pk).status_code, 200)
|
||||
|
||||
def test_a_guardian_can_still_be_put_in_a_group(self):
|
||||
# The stated exception: a parent may well sit on a committee.
|
||||
self.client.force_login(self.admin_user)
|
||||
self.club_post("family_create", self.family_payload())
|
||||
parent = Member.objects.get(first_name="Pat", last_name="Parent")
|
||||
group = Group.objects.create(club=self.club, name="Committee")
|
||||
|
||||
response = self.club_get("group_bulk_add", group.pk)
|
||||
|
||||
self.assertContains(response, str(parent.pk))
|
||||
|
||||
def test_a_guardian_is_not_offered_for_a_team_roster(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
self.club_post("family_create", self.family_payload())
|
||||
parent = Member.objects.get(first_name="Pat", last_name="Parent")
|
||||
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
|
||||
|
||||
response = self.club_get("team_bulk_add", team.pk, self.season.pk)
|
||||
|
||||
self.assertNotContains(response, str(parent.pk))
|
||||
|
||||
|
||||
class MemberListFamilyColumnTests(ManagementTestBase):
|
||||
"""The member list is one flat table -- family is a column (each member's family/
|
||||
role attached in Python, management.views.MemberListView), not a grouping."""
|
||||
@@ -1878,6 +1967,7 @@ class MemberClubMembershipFormTests(ManagementTestBase):
|
||||
{
|
||||
"first_name": "Fee",
|
||||
"last_name": "Payer",
|
||||
"kind": ClubMembership.Kind.MEMBER,
|
||||
"license": "BE-9999",
|
||||
"status": ClubMembership.StatusChoices.ACTIVE,
|
||||
"fee_status": ClubMembership.FeeStatus.PAID,
|
||||
@@ -1912,7 +2002,7 @@ class MemberClubMembershipFormTests(ManagementTestBase):
|
||||
|
||||
response = self.club_post(
|
||||
"member_update",
|
||||
{"first_name": "Unrostered", "last_name": "Member", "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.PAID},
|
||||
{"first_name": "Unrostered", "last_name": "Member", "kind": ClubMembership.Kind.MEMBER, "status": ClubMembership.StatusChoices.ACTIVE, "fee_status": ClubMembership.FeeStatus.PAID},
|
||||
member.pk,
|
||||
)
|
||||
|
||||
@@ -2893,6 +2983,52 @@ class MemberBulkImportTests(ManagementTestBase):
|
||||
self.assertEqual(FamilyMembership.objects.get(family=family, member=parent).role, FamilyMembership.FamilyRole.PARENT)
|
||||
self.assertEqual(FamilyMembership.objects.get(family=family, member=child).role, FamilyMembership.FamilyRole.CHILD)
|
||||
|
||||
def test_membership_kind_guardian_creates_a_guardian_not_a_member(self):
|
||||
# Columns are positional; membership_kind is the last one.
|
||||
upload = make_import_workbook(
|
||||
[
|
||||
["Taylor", "Doe", "", "taylor.guardian@example.com", "", "", "", "", "", "Doe family", "parent", "guardian"],
|
||||
["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "member"],
|
||||
]
|
||||
)
|
||||
self.club_post("member_import", {"file": upload})
|
||||
|
||||
self.club_post("member_import_confirm", {})
|
||||
|
||||
parent = Member.objects.get(email="taylor.guardian@example.com")
|
||||
child = Member.objects.get(first_name="Jamie", last_name="Doe")
|
||||
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
|
||||
self.assertEqual(ClubMembership.objects.get(club=self.club, member=child).kind, ClubMembership.Kind.MEMBER)
|
||||
|
||||
def test_a_blank_membership_kind_still_means_member(self):
|
||||
# Every file written before the column existed carried this implicitly.
|
||||
upload = make_import_workbook([["Solo", "Blankkind", "", "solo.blank@example.com", "", "", "", "", "", "", "", ""]])
|
||||
self.club_post("member_import", {"file": upload})
|
||||
|
||||
self.club_post("member_import_confirm", {})
|
||||
|
||||
member = Member.objects.get(email="solo.blank@example.com")
|
||||
self.assertEqual(ClubMembership.objects.get(club=self.club, member=member).kind, ClubMembership.Kind.MEMBER)
|
||||
|
||||
def test_a_child_marked_as_a_guardian_is_an_error(self):
|
||||
# A child is the member the guardian is attached *to*.
|
||||
upload = make_import_workbook([["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child", "guardian"]])
|
||||
|
||||
response = self.club_post("member_import", {"file": upload})
|
||||
|
||||
result = response.context["results"][0]
|
||||
self.assertIsNone(result["member"])
|
||||
self.assertTrue(any("child is always a member" in error.lower() for error in result["errors"]))
|
||||
|
||||
def test_an_invalid_membership_kind_is_reported(self):
|
||||
upload = make_import_workbook([["Odd", "Kind", "", "odd.kind@example.com", "", "", "", "", "", "", "", "sponsor"]])
|
||||
|
||||
response = self.club_post("member_import", {"file": upload})
|
||||
|
||||
result = response.context["results"][0]
|
||||
self.assertIsNone(result["member"])
|
||||
self.assertTrue(any("membership_kind" in error for error in result["errors"]))
|
||||
|
||||
def test_family_role_without_a_group_is_an_error(self):
|
||||
upload = make_import_workbook([["Odd", "Row", "", "odd.row@example.com", "", "", "", "", "", "", "parent"]])
|
||||
|
||||
|
||||
@@ -239,7 +239,9 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
|
||||
if season is None:
|
||||
return ClubMembership.objects.none()
|
||||
|
||||
memberships = ClubMembership.objects.filter(club=self.request.club, season=season).select_related("member").order_by("member__last_name", "member__first_name")
|
||||
# kind=MEMBER: a guardian holds no membership and owes no fee, so they
|
||||
# belong in neither this list nor the KPIs below it.
|
||||
memberships = ClubMembership.objects.filter(club=self.request.club, season=season, kind=ClubMembership.Kind.MEMBER).select_related("member").order_by("member__last_name", "member__first_name")
|
||||
|
||||
fee_status = self.request.GET.get("fee_status", "not_paid")
|
||||
if fee_status == "not_paid":
|
||||
@@ -279,7 +281,7 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
|
||||
|
||||
counts = {}
|
||||
if current is not None:
|
||||
counts = {row["fee_status"]: row["count"] for row in ClubMembership.objects.filter(club=club, season=current).values("fee_status").annotate(count=Count("id"))}
|
||||
counts = {row["fee_status"]: row["count"] for row in ClubMembership.objects.filter(club=club, season=current, kind=ClubMembership.Kind.MEMBER).values("fee_status").annotate(count=Count("id"))}
|
||||
paid = counts.get(ClubMembership.FeeStatus.PAID, 0)
|
||||
partial = counts.get(ClubMembership.FeeStatus.PARTIALLY_PAID, 0)
|
||||
unpaid = counts.get(ClubMembership.FeeStatus.UNPAID, 0)
|
||||
@@ -569,7 +571,7 @@ class MemberUpdateView(ClubAdminRequiredMixin, View):
|
||||
template_name = "management/member_form.html"
|
||||
|
||||
def get_member(self):
|
||||
return get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
|
||||
return get_object_or_404(members_visible_to(self.request.user, self.request.club, include_guardians=True), pk=self.kwargs["pk"])
|
||||
|
||||
def get_membership(self, member):
|
||||
season = current_season(self.request.club)
|
||||
@@ -607,10 +609,10 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
context_object_name = "member"
|
||||
|
||||
def get_queryset(self):
|
||||
return members_visible_to(self.request.user, self.request.club)
|
||||
return members_visible_to(self.request.user, self.request.club, include_guardians=True)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
visible = members_visible_to(self.request.user, self.request.club)
|
||||
visible = members_visible_to(self.request.user, self.request.club, include_guardians=True)
|
||||
my_family_ids = FamilyMembership.objects.filter(member=self.object).values_list("family_id", flat=True)
|
||||
family_scoped_members = visible.filter(family_memberships__family_id__in=my_family_ids).distinct()
|
||||
family_groups, _ = group_by_family(family_scoped_members)
|
||||
@@ -647,11 +649,11 @@ class MemberAttachToFamilyView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, F
|
||||
return {"pk": self.kwargs["pk"]}
|
||||
|
||||
def get_form_kwargs(self):
|
||||
member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
|
||||
member = get_object_or_404(members_visible_to(self.request.user, self.request.club, include_guardians=True), pk=self.kwargs["pk"])
|
||||
return super().get_form_kwargs() | {"club": self.request.club, "member": member}
|
||||
|
||||
def form_valid(self, form):
|
||||
member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
|
||||
member = get_object_or_404(members_visible_to(self.request.user, self.request.club, include_guardians=True), pk=self.kwargs["pk"])
|
||||
family = attach_to_family(member, role=form.cleaned_data["role"], family=form.cleaned_data["family"])
|
||||
body = _("“%(member)s” is now part of %(family)s.") % {"member": member, "family": family}
|
||||
notify(self.request, f"s|{_('Added to family')}|{body}")
|
||||
@@ -671,7 +673,7 @@ class MemberRefereeEligibilityUpdateView(ClubAdminRequiredMixin, RedirectOnInval
|
||||
return {"pk": self.kwargs["pk"]}
|
||||
|
||||
def get_member(self):
|
||||
return get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
|
||||
return get_object_or_404(members_visible_to(self.request.user, self.request.club, include_guardians=True), pk=self.kwargs["pk"])
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"club": self.request.club, "member": self.get_member()}
|
||||
@@ -697,7 +699,7 @@ class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormV
|
||||
return {"pk": self.kwargs["pk"]}
|
||||
|
||||
def form_valid(self, form):
|
||||
member = get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
|
||||
member = get_object_or_404(members_visible_to(self.request.user, self.request.club, include_guardians=True), pk=self.kwargs["pk"])
|
||||
if member.user_id is not None:
|
||||
# Already has one -- the row's button shouldn't have been there at all;
|
||||
# a direct POST replay (e.g. a resubmitted form) is the only way here.
|
||||
@@ -710,7 +712,7 @@ class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormV
|
||||
|
||||
class MemberDetachFromFamilyView(ClubAdminRequiredMixin, View):
|
||||
def post(self, request, pk, family_pk):
|
||||
member = get_object_or_404(members_visible_to(request.user, request.club), pk=pk)
|
||||
member = get_object_or_404(members_visible_to(request.user, request.club, include_guardians=True), pk=pk)
|
||||
family = get_object_or_404(Family, pk=family_pk, memberships__member=member)
|
||||
# detach_from_family may delete `family` itself (left empty) -- str() it first,
|
||||
# since Family.__str__ queries self.memberships, which needs a pk to still exist.
|
||||
@@ -752,7 +754,7 @@ class FamilyMembershipRoleUpdateView(ClubAdminRequiredMixin, View):
|
||||
|
||||
class MemberDeleteView(ClubAdminRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
member = get_object_or_404(members_visible_to(request.user, request.club), pk=pk)
|
||||
member = get_object_or_404(members_visible_to(request.user, request.club, include_guardians=True), pk=pk)
|
||||
name = str(member)
|
||||
# FamilyMembership cascades away with the member -- note which families
|
||||
# they were in before that happens, so an emptied one can be cleaned up
|
||||
@@ -1344,6 +1346,7 @@ class FamilyCreateView(ClubAdminRequiredMixin, FormView):
|
||||
child_first_name=cd["child_first_name"],
|
||||
child_last_name=cd["child_last_name"],
|
||||
child_date_of_birth=cd["child_date_of_birth"],
|
||||
parent_is_member=cd["parent_is_member"],
|
||||
)
|
||||
|
||||
if season is not None:
|
||||
@@ -1372,7 +1375,7 @@ class FamilyDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
return families_of_club(self.request.club)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
visible = members_visible_to(self.request.user, self.request.club)
|
||||
visible = members_visible_to(self.request.user, self.request.club, include_guardians=True)
|
||||
members = visible.filter(family_memberships__family=self.object).distinct()
|
||||
# group_by_family scopes by member, not family -- a member visible here
|
||||
# because they're in *this* family can also belong to another one, in
|
||||
@@ -1541,7 +1544,7 @@ class RefereeListView(ClubStaffRequiredMixin, ListView):
|
||||
context_object_name = "referees"
|
||||
|
||||
def get_queryset(self):
|
||||
members = members_visible_to(self.request.user, self.request.club).filter(referee_profile__isnull=False)
|
||||
members = members_visible_to(self.request.user, self.request.club, include_guardians=True).filter(referee_profile__isnull=False)
|
||||
return members.select_related("referee_profile", "referee_profile__level").prefetch_related("referee_profile__level__teams").order_by("last_name", "first_name")
|
||||
|
||||
|
||||
@@ -1629,7 +1632,7 @@ class GroupBulkAddView(ClubAdminRequiredMixin, View):
|
||||
|
||||
def get_form_kwargs(self, group):
|
||||
existing_ids = set(GroupMembership.objects.filter(group=group).values_list("member_id", flat=True))
|
||||
members = members_visible_to(self.request.user, self.request.club).order_by("last_name", "first_name")
|
||||
members = members_visible_to(self.request.user, self.request.club, include_guardians=True).order_by("last_name", "first_name")
|
||||
# One member query for the whole formset -- see TeamBulkAddView.get_form_kwargs.
|
||||
member_choices = [("", "---------")] + [(member.pk, _("%(member)s — already in this group") % {"member": member} if member.pk in existing_ids else str(member)) for member in members]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user