Add parent claims: onboarding a roster of children with no parents on file
The migration path for a club arriving with a list of children from a federation export and no parent records. Children import without logins, each into a family of their own -- that shape *is* the "nobody is responsible for this child" state, so there's no unclaimed flag to drift out of step with reality, and a family drops off the worklist by itself the moment a parent joins it. `family_role=child` with a blank `family_group` asks for that; any other lone role is still a mistake in the file. Verification is a human decision, deliberately. A parent submits a public form with the child's name and date of birth as free text -- no search, no autocomplete, and the same response whether or not the child was found, because the page needs no login and anything that resolved the child would turn it into a way to enumerate the club's children. An admin matches it from a queue against a shortlist that only ever contains children with nobody on file, so approving can never quietly re-parent a child who already has one. The alternatives were worse. A claim code needs a delivery channel the club may not have and is a bearer token besides. Matching on name plus birthday hands out someone else's child to whoever guesses a birthday. The club is the only party that actually knows its own families. That form is also the registration: open self-registration is now closed (shadowing account_signup rather than removing the route, so the URL name allauth's templates reverse still resolves). The account is created on approval, not on submission, so a public form can't fill the user table. An approved parent lands as a guardian -- login and family link, no membership, no fee -- gets a password-reset link, and a minimal "my family" page. One bug worth recording: families_awaiting_a_parent first used annotate(Count(..., filter=...)) over a queryset already filtered on the same join, so Django reused that join for the counts and a parent with no ClubMembership of their own -- exactly what a newly linked guardian is -- went uncounted, leaving the family unclaimed forever. Exists subqueries avoid it. A test pins both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -211,8 +211,15 @@ def _parse_family_fields(raw):
|
||||
family_role_raw = raw.get("family_role", "").strip()
|
||||
|
||||
if not family_group:
|
||||
# family_role=child on its own is the migration case this exists for: a
|
||||
# child the club holds with no parent on file yet. It gets a family of
|
||||
# its own so there is something for a parent to join later -- see
|
||||
# members.services.claims. Any other lone role is still a mistake.
|
||||
if family_role_raw:
|
||||
errors.append(_("family_role given without a family_group."))
|
||||
family_role = _match_choice(family_role_raw, FamilyMembership.FamilyRole)
|
||||
if family_role == FamilyMembership.FamilyRole.CHILD:
|
||||
return "", family_role, errors
|
||||
errors.append(_("family_role given without a family_group. Only 'child' is allowed on its own, for a child whose parent will register later."))
|
||||
return "", None, errors
|
||||
|
||||
if not family_role_raw:
|
||||
|
||||
@@ -47,6 +47,9 @@ _NAV_SECTIONS = {
|
||||
"role_list": "role_list",
|
||||
"role_create": "role_list",
|
||||
"role_revoke": "role_list",
|
||||
"parent_claim_list": "parent_claim_list",
|
||||
"parent_claim_approve": "parent_claim_list",
|
||||
"parent_claim_reject": "parent_claim_list",
|
||||
"group_list": "group_list",
|
||||
"group_create": "group_list",
|
||||
"group_detail": "group_list",
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
|
||||
<li class="menu-title">{% trans "People" %}</li>
|
||||
<li><a class="{% if nav == 'member_list' %}menu-active{% endif %}" href="{% url 'management:member_list' %}">{% lucide "users" size=16 %} {% trans "Members" %}</a></li>
|
||||
{% if is_club_admin %}
|
||||
<li><a class="{% if nav == 'parent_claim_list' %}menu-active{% endif %}" href="{% url 'management:parent_claim_list' %}">{% lucide "inbox" size=16 %} {% trans "Parent claims" %}</a></li>
|
||||
{% endif %}
|
||||
{% if is_club_admin %}
|
||||
<li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li>
|
||||
<li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li>
|
||||
|
||||
108
management/templates/management/parent_claim_list.html
Normal file
108
management/templates/management/parent_claim_list.html
Normal file
@@ -0,0 +1,108 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide ui %}
|
||||
|
||||
{% block heading %}{% trans "Parent claims" %}{% endblock heading %}
|
||||
{% block subheading %}{% trans "Parents asking to be linked to a child the club already has on file." %}{% endblock subheading %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="card bg-base-100 shadow mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "inbox" size=18 %} {% trans "Waiting for review" %}</h2>
|
||||
<p class="text-sm opacity-70">
|
||||
{% blocktrans %}Check each request against your own records before approving. The shortlist only ever contains children who have nobody on file, so approving can never re-parent a child who already has one. An approved parent is added as a guardian: they get the login, but owe no fee and are not counted as a member.{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
{% for claim in pending %}
|
||||
<div class="border border-base-300 rounded-box p-4 mt-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-xs opacity-70">{% trans "Parent says they are" %}</div>
|
||||
<div class="font-semibold">{{ claim.parent_name }}</div>
|
||||
<div class="text-sm opacity-70">{{ claim.parent_email }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs opacity-70">{% trans "Parent of" %}</div>
|
||||
<div class="font-semibold">{{ claim.claimed_child_name }}</div>
|
||||
<div class="text-sm opacity-70">{{ claim.child_date_of_birth|date:"j F Y" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-2 mt-4">
|
||||
{% if claim.has_candidates %}
|
||||
<form method="post" action="{% url 'management:parent_claim_approve' claim.pk %}" class="flex flex-wrap items-end gap-2">
|
||||
{% csrf_token %}
|
||||
<div class="min-w-64">{% form_field claim.review_form.child %}</div>
|
||||
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "check" size=14 %} {% trans "Approve" %}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">{% trans "No child without a parent matches this. Check the spelling and the date of birth with them before rejecting." %}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'management:parent_claim_reject' claim.pk %}" class="flex items-end gap-2">
|
||||
{% csrf_token %}
|
||||
<input type="text" name="note" class="input input-bordered input-sm" placeholder="{% trans 'Reason (optional)' %}">
|
||||
<button class="btn btn-outline btn-error btn-sm gap-2" type="submit">{% lucide "x" size=14 %} {% trans "Reject" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-sm opacity-60 mt-2">{% trans "Nothing waiting." %}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "user-search" size=18 %} {% trans "Children with nobody on file" %}</h2>
|
||||
<p class="text-sm opacity-70">{% trans "Imported without a parent. They stay here until someone claims them." %}</p>
|
||||
<ul class="divide-y divide-base-200">
|
||||
{% for child in awaiting_a_parent %}
|
||||
<li class="py-2 flex items-center justify-between">
|
||||
<a class="link link-hover" href="{% url 'management:member_detail' child.pk %}">{{ child }}</a>
|
||||
<span class="text-sm opacity-70">{{ child.date_of_birth|date:"j F Y"|default:"—" }}</span>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="py-2 text-sm opacity-60">{% trans "Every child has a parent linked." %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if reviewed %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "history" size=18 %} {% trans "Already dealt with" %}</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Parent" %}</th>
|
||||
<th>{% trans "Claimed" %}</th>
|
||||
<th>{% trans "Outcome" %}</th>
|
||||
<th>{% trans "Reviewed by" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for claim in reviewed %}
|
||||
<tr>
|
||||
<td>{{ claim.parent_name }}<div class="text-xs opacity-60">{{ claim.parent_email }}</div></td>
|
||||
<td>{{ claim.claimed_child_name }}</td>
|
||||
<td>
|
||||
{% if claim.status == "approved" %}
|
||||
<span class="badge badge-success badge-sm">{% trans "Approved" %}</span>
|
||||
{% if claim.child %}<div class="text-xs opacity-60">{{ claim.child }}</div>{% endif %}
|
||||
{% else %}
|
||||
<span class="badge badge-error badge-sm">{% trans "Rejected" %}</span>
|
||||
{% if claim.note %}<div class="text-xs opacity-60">{{ claim.note }}</div>{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ claim.reviewed_by|default:"—" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock panel %}
|
||||
@@ -25,7 +25,8 @@ from events.services.recurrence import detach_occurrence, generate_occurrences
|
||||
from management.bulk_import import TEMPLATE_COLUMNS
|
||||
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 members.models import Family, FamilyMembership, Group, GroupMembership, Member
|
||||
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
|
||||
from members.services.claims import children_awaiting_a_parent
|
||||
from news.models import News, NewsPhoto
|
||||
from shop.models import Order
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
@@ -1363,6 +1364,148 @@ class FamilyManagementTests(ManagementTestBase):
|
||||
self.assertEqual(family.guardians.count(), 1)
|
||||
|
||||
|
||||
class ParentClaimViewTests(ManagementTestBase):
|
||||
"""The public claim form and the admin review queue -- see
|
||||
members.services.claims; the service-level guarantees live in
|
||||
members.tests.ParentClaimTests."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
cls.child = Member.objects.create(first_name="Jamie", last_name="Doe", date_of_birth=datetime.date(2014, 3, 2))
|
||||
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
family = Family.objects.create()
|
||||
FamilyMembership.objects.create(family=family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
|
||||
|
||||
def claim_payload(self, **overrides):
|
||||
payload = {
|
||||
"parent_first_name": "Taylor",
|
||||
"parent_last_name": "Doe",
|
||||
"parent_email": "taylor.doe@example.com",
|
||||
"child_first_name": "Jamie",
|
||||
"child_last_name": "Doe",
|
||||
"child_date_of_birth": "2014-03-02",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
def submit(self, **overrides):
|
||||
return self.client.post(reverse("members:parent_claim"), self.claim_payload(**overrides), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
def test_the_claim_form_is_reachable_without_signing_in(self):
|
||||
response = self.client.get(reverse("members:parent_claim"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_submitting_records_a_pending_claim(self):
|
||||
response = self.submit()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(ParentClaim.objects.filter(club=self.club, status=ParentClaim.Status.PENDING).count(), 1)
|
||||
|
||||
def test_an_unmatched_claim_looks_exactly_like_a_matched_one(self):
|
||||
# The page must not tell an anonymous submitter which children exist.
|
||||
matched = self.submit()
|
||||
unmatched = self.submit(child_first_name="Nobody", child_last_name="Here", parent_email="other@example.com")
|
||||
|
||||
self.assertEqual(matched.status_code, unmatched.status_code)
|
||||
self.assertEqual(matched.content, unmatched.content)
|
||||
|
||||
def test_submitting_creates_no_account(self):
|
||||
# A public form that made a User per submission would be a spam magnet;
|
||||
# the account is created on approval, once a human has vouched for it.
|
||||
self.submit()
|
||||
|
||||
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
|
||||
|
||||
def test_open_signup_is_closed(self):
|
||||
response = self.client.get(reverse("account_signup"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_the_queue_is_admin_only(self):
|
||||
coach_user = User.objects.create_user(email="coach-claims@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="First Team", short_name="1st")
|
||||
position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
|
||||
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
|
||||
self.client.force_login(coach_user)
|
||||
|
||||
self.assertEqual(self.club_get("parent_claim_list").status_code, 403)
|
||||
|
||||
def test_the_queue_lists_a_pending_claim_and_the_unclaimed_child(self):
|
||||
self.submit()
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("parent_claim_list")
|
||||
|
||||
self.assertContains(response, "taylor.doe@example.com")
|
||||
self.assertContains(response, "Jamie Doe")
|
||||
|
||||
def test_approving_links_the_parent_as_a_guardian(self):
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
|
||||
|
||||
parent = Member.objects.get(user__email="taylor.doe@example.com")
|
||||
self.assertEqual(ClubMembership.objects.get(club=self.club, member=parent).kind, ClubMembership.Kind.GUARDIAN)
|
||||
claim.refresh_from_db()
|
||||
self.assertEqual(claim.status, ParentClaim.Status.APPROVED)
|
||||
|
||||
def test_approving_without_choosing_a_child_changes_nothing(self):
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("parent_claim_approve", {}, claim.pk)
|
||||
|
||||
claim.refresh_from_db()
|
||||
self.assertTrue(claim.is_pending)
|
||||
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
|
||||
|
||||
def test_a_child_who_already_has_a_parent_cannot_be_chosen(self):
|
||||
# The shortlist is only ever children with nobody on file, so approving
|
||||
# can never quietly re-parent a child who already has one.
|
||||
other_child = Member.objects.create(first_name="Sam", last_name="Roe", date_of_birth=datetime.date(2013, 1, 1))
|
||||
ClubMembership.objects.create(club=self.club, member=other_child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
family = Family.objects.create()
|
||||
FamilyMembership.objects.create(family=family, member=other_child, role=FamilyMembership.FamilyRole.CHILD)
|
||||
existing_parent = Member.objects.create(first_name="Existing", last_name="Roe")
|
||||
FamilyMembership.objects.create(family=family, member=existing_parent, role=FamilyMembership.FamilyRole.PARENT)
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("parent_claim_approve", {"child": str(other_child.pk)}, claim.pk)
|
||||
|
||||
claim.refresh_from_db()
|
||||
self.assertTrue(claim.is_pending)
|
||||
|
||||
def test_rejecting_records_the_reason_and_links_nobody(self):
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
self.club_post("parent_claim_reject", {"note": "Not on our records."}, claim.pk)
|
||||
|
||||
claim.refresh_from_db()
|
||||
self.assertEqual(claim.status, ParentClaim.Status.REJECTED)
|
||||
self.assertFalse(User.objects.filter(email="taylor.doe@example.com").exists())
|
||||
|
||||
def test_the_parent_sees_their_child_after_approval(self):
|
||||
self.submit()
|
||||
claim = ParentClaim.objects.get(club=self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
self.club_post("parent_claim_approve", {"child": str(self.child.pk)}, claim.pk)
|
||||
|
||||
self.client.force_login(User.objects.get(email="taylor.doe@example.com"))
|
||||
response = self.client.get(reverse("members:my_family"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertContains(response, "Jamie Doe")
|
||||
|
||||
|
||||
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
|
||||
@@ -3010,6 +3153,29 @@ class MemberBulkImportTests(ManagementTestBase):
|
||||
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_lone_child_row_gets_a_family_of_its_own(self):
|
||||
# The migration case: children arrive with no parents on file. A family of
|
||||
# one is what makes "nobody responsible for this child" visible -- see
|
||||
# members.services.claims.families_awaiting_a_parent.
|
||||
upload = make_import_workbook([["Jamie", "Lonechild", "2014-03-02", "", "", "", "", "", "", "", "child", ""]])
|
||||
self.club_post("member_import", {"file": upload})
|
||||
|
||||
self.club_post("member_import_confirm", {})
|
||||
|
||||
child = Member.objects.get(first_name="Jamie", last_name="Lonechild")
|
||||
self.assertIn(child, children_awaiting_a_parent(self.club))
|
||||
|
||||
def test_a_lone_parent_row_is_still_an_error(self):
|
||||
# Only `child` is meaningful without a family_group; a parent with nobody
|
||||
# to be a parent *of* is a mistake in the file.
|
||||
upload = make_import_workbook([["Odd", "Loneparent", "", "odd.lone@example.com", "", "", "", "", "", "", "parent", ""]])
|
||||
|
||||
response = self.club_post("member_import", {"file": upload})
|
||||
|
||||
result = response.context["results"][0]
|
||||
self.assertIsNone(result["member"])
|
||||
self.assertTrue(any("family_group" in error for error in result["errors"]))
|
||||
|
||||
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"]])
|
||||
|
||||
@@ -36,6 +36,9 @@ urlpatterns = [
|
||||
path("roles/", views.ClubRoleListView.as_view(), name="role_list"),
|
||||
path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"),
|
||||
path("roles/<uuid:pk>/revoke/", views.ClubRoleRevokeView.as_view(), name="role_revoke"),
|
||||
path("parent-claims/", views.ParentClaimListView.as_view(), name="parent_claim_list"),
|
||||
path("parent-claims/<uuid:pk>/approve/", views.ParentClaimApproveView.as_view(), name="parent_claim_approve"),
|
||||
path("parent-claims/<uuid:pk>/reject/", views.ParentClaimRejectView.as_view(), name="parent_claim_reject"),
|
||||
path("groups/", views.GroupListView.as_view(), name="group_list"),
|
||||
path("groups/new/", views.GroupCreateView.as_view(), name="group_create"),
|
||||
path("groups/<uuid:pk>/", views.GroupDetailView.as_view(), name="group_detail"),
|
||||
|
||||
@@ -38,7 +38,9 @@ from events.services.recurrence import cancel_occurrence, detach_occurrence, gen
|
||||
from events.services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
|
||||
from formbuilder.models import Form as FormBuilderForm
|
||||
from formbuilder.models import Submission
|
||||
from members.models import Family, FamilyMembership, Group, GroupMembership, Member
|
||||
from members.forms import ClaimReviewForm
|
||||
from members.models import Family, FamilyMembership, Group, GroupMembership, Member, ParentClaim
|
||||
from members.services.claims import ClaimError, approve_claim, children_awaiting_a_parent, reject_claim, suggested_children
|
||||
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, get_or_create_login_user, grant_login, register_family
|
||||
from news.models import News, NewsPhoto
|
||||
from shop.models import Discount, Invoice, Order, Product
|
||||
@@ -515,6 +517,12 @@ class MemberImportConfirmView(ClubAdminRequiredMixin, View):
|
||||
family = Family.objects.create()
|
||||
families_by_group[family_group] = family
|
||||
FamilyMembership.objects.create(family=family, member=member, role=family_role)
|
||||
elif family_role == FamilyMembership.FamilyRole.CHILD:
|
||||
# A child with no family_group: nobody is on file for them yet.
|
||||
# A family of their own is what makes that state visible -- it's
|
||||
# what members.services.claims.families_awaiting_a_parent looks
|
||||
# for, and what an approved claim adds the parent to.
|
||||
FamilyMembership.objects.create(family=Family.objects.create(), member=member, role=family_role)
|
||||
|
||||
if season is not None:
|
||||
ClubMembership.objects.create(club=request.club, member=member, season=season, signed_up_at=timezone.localdate(), **result["membership_kwargs"])
|
||||
@@ -1432,6 +1440,71 @@ class FamilyAddParentView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormVi
|
||||
return redirect("management:family_detail", pk=family.pk)
|
||||
|
||||
|
||||
class ParentClaimListView(ClubAdminRequiredMixin, ListView):
|
||||
"""The review queue for parents asking to be linked to a child.
|
||||
|
||||
Approving is a human decision on purpose -- see members.models.ParentClaim.
|
||||
Each pending claim is shown with what the parent typed *and* a shortlist of
|
||||
children who have nobody on file, so the admin matches rather than searches.
|
||||
"""
|
||||
|
||||
template_name = "management/parent_claim_list.html"
|
||||
context_object_name = "claims"
|
||||
|
||||
def get_queryset(self):
|
||||
return ParentClaim.objects.filter(club=self.request.club).select_related("child", "reviewed_by")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
claims = list(self.get_queryset())
|
||||
pending = [claim for claim in claims if claim.is_pending]
|
||||
for claim in pending:
|
||||
candidates = suggested_children(claim)
|
||||
claim.review_form = ClaimReviewForm(candidates=Member.objects.filter(pk__in=[child.pk for child in candidates]))
|
||||
claim.has_candidates = bool(candidates)
|
||||
|
||||
return super().get_context_data(
|
||||
pending=pending,
|
||||
reviewed=[claim for claim in claims if not claim.is_pending],
|
||||
awaiting_a_parent=children_awaiting_a_parent(self.request.club).order_by("last_name", "first_name"),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class ParentClaimApproveView(ClubAdminRequiredMixin, View):
|
||||
"""Link the claim's parent to the chosen child. The parent lands as a
|
||||
guardian, not a member -- see members.services.claims.approve_claim."""
|
||||
|
||||
def post(self, request, pk):
|
||||
claim = get_object_or_404(ParentClaim.objects.filter(club=request.club), pk=pk)
|
||||
form = ClaimReviewForm(request.POST, candidates=children_awaiting_a_parent(request.club))
|
||||
if not form.is_valid():
|
||||
notify(request, f"e|{_('Could not approve')}|{_('Choose which child this claim refers to.')}")
|
||||
return redirect("management:parent_claim_list")
|
||||
|
||||
reviewer = Member.objects.filter(user=request.user).first()
|
||||
try:
|
||||
approve_claim(claim, child=form.cleaned_data["child"], season=current_season(request.club), reviewed_by=reviewer)
|
||||
except ClaimError as error:
|
||||
notify(request, f"e|{_('Could not approve')}|{error}")
|
||||
else:
|
||||
body = _("“%(parent)s” is now linked to %(child)s and can set up their login.") % {"parent": claim.parent_name, "child": form.cleaned_data["child"]}
|
||||
notify(request, f"s|{_('Claim approved')}|{body}")
|
||||
return redirect("management:parent_claim_list")
|
||||
|
||||
|
||||
class ParentClaimRejectView(ClubAdminRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
claim = get_object_or_404(ParentClaim.objects.filter(club=request.club), pk=pk)
|
||||
reviewer = Member.objects.filter(user=request.user).first()
|
||||
try:
|
||||
reject_claim(claim, reviewed_by=reviewer, note=request.POST.get("note", "").strip())
|
||||
except ClaimError as error:
|
||||
notify(request, f"e|{_('Could not reject')}|{error}")
|
||||
else:
|
||||
notify(request, f"w|{_('Claim rejected')}|" + _("“%(parent)s” was not linked.") % {"parent": claim.parent_name})
|
||||
return redirect("management:parent_claim_list")
|
||||
|
||||
|
||||
class PositionListView(ClubStaffRequiredMixin, ListView):
|
||||
"""Visible to any staff (coaches need to see positions to make sense of a
|
||||
roster); creating/editing positions is still ADMIN-only, gated in the
|
||||
|
||||
Reference in New Issue
Block a user