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:
@@ -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"]])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user