Show blocked sign-ups on mobile instead of silently hiding the event

A member excluded from an event's Attendance sync by an open onboarding
requirement (events.services.attendance.effective_members) previously just
never saw that event anywhere -- no row, no explanation. Two new read-side
functions mirror that exclusion instead of hiding it: club.services.
onboarding.open_requirements_blocking (per-member, "why") and events.
services.attendance.blocked_upcoming_events_for_member (which of their
upcoming events are affected).

The Calendar now shows those events as a distinct muted "Blocked" row
naming the outstanding requirement, and the event detail page shows a
"Can't sign up yet" card for the same reason -- no RSVP buttons, no lineup/
referee actions, just the explanation. Write-side blocking (who actually
gets an Attendance row, who a coach can select) is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-22 19:24:11 +02:00
parent 86ea8d1b15
commit a7fdab4352
11 changed files with 324 additions and 15 deletions

View File

@@ -172,6 +172,28 @@ def blocked_member_ids_for_event(club, season, event_kind) -> set:
return blocked_member_ids return blocked_member_ids
def open_requirements_blocking(member, club, season, event_kind) -> list:
"""The specific, still-open `OnboardingRequirement`s blocking `member` from
`event_kind` events this season -- the per-member, explain-*why* mirror of
`blocked_member_ids_for_event`'s bulk set. Built for the member-facing "you
can't sign up for this yet" card (mobile app): unlike that function, this
one is meant to be called once for a person looking at one event, not once
per event for a whole club, so the per-member cost here is fine.
Empty (never blocked) for a member with no current-season ClubMembership.MEMBER
row -- same "nothing to check" reasoning as blocked_member_ids_for_event's own."""
membership = ClubMembership.objects.filter(club=club, season=season, member=member, kind=ClubMembership.Kind.MEMBER).first()
if membership is None:
return []
blocking = [requirement for requirement in OnboardingRequirement.objects.filter(club=club, is_active=True) if event_kind in requirement.blocked_event_kinds]
if not blocking:
return []
resolved_ids = set(MemberRequirementStatus.objects.filter(membership=membership, requirement__in=blocking).filter(_RESOLVED).values_list("requirement_id", flat=True))
return [requirement for requirement in blocking if requirement.pk not in resolved_ids]
#: Fee states "clean" enough to activate on -- PARTIALLY_PAID/UNPAID never are. #: Fee states "clean" enough to activate on -- PARTIALLY_PAID/UNPAID never are.
_CLEAN_FEE_STATUSES = (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED) _CLEAN_FEE_STATUSES = (ClubMembership.FeeStatus.PAID, ClubMembership.FeeStatus.WAIVED)

View File

@@ -51,6 +51,7 @@ from .services.onboarding import (
mark_bypassed, mark_bypassed,
mark_complete, mark_complete,
mark_incomplete, mark_incomplete,
open_requirements_blocking,
) )
from .services.seasons import _initial_season_start, _season_end, generate_seasons, resync_seasons from .services.seasons import _initial_season_start, _season_end, generate_seasons, resync_seasons
from .tenancy import ( from .tenancy import (
@@ -2268,6 +2269,34 @@ class OnboardingRequirementTests(TestCase):
self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set()) self.assertEqual(blocked_member_ids_for_event(self.club, self.season, "game"), set())
# --- open_requirements_blocking (per-member "why can't I sign up" mirror) ---
def test_open_requirements_blocking_is_empty_when_nothing_is_configured_to_block(self):
self.assertEqual(open_requirements_blocking(self.member, self.club, self.season, "game"), [])
def test_open_requirements_blocking_returns_the_open_requirement(self):
self.medical.blocked_event_kinds = ["game"]
self.medical.save()
self.assertEqual(open_requirements_blocking(self.member, self.club, self.season, "game"), [self.medical])
def test_open_requirements_blocking_is_kind_specific(self):
self.medical.blocked_event_kinds = ["game"]
self.medical.save()
self.assertEqual(open_requirements_blocking(self.member, self.club, self.season, "training"), [])
def test_open_requirements_blocking_excludes_a_resolved_requirement(self):
self.medical.blocked_event_kinds = ["game"]
self.medical.save()
mark_complete(self.membership, self.medical, user=self.staff)
self.assertEqual(open_requirements_blocking(self.member, self.club, self.season, "game"), [])
def test_open_requirements_blocking_is_empty_with_no_club_membership(self):
stranger = Member.objects.create(first_name="No", last_name="Membership")
self.assertEqual(open_requirements_blocking(stranger, self.club, self.season, "game"), [])
# --- approve_all_clean --- # --- approve_all_clean ---
def test_approve_all_clean_activates_a_pending_paid_up_fully_checked_member(self): def test_approve_all_clean_activates_a_pending_paid_up_fully_checked_member(self):
pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID) pending = ClubMembership.objects.create(club=self.club, member=Member.objects.create(first_name="Tom", last_name="Roe"), season=self.season, status=ClubMembership.StatusChoices.PENDING, fee_status=ClubMembership.FeeStatus.PAID)

View File

@@ -1,4 +1,5 @@
from .attendance import ( from .attendance import (
blocked_upcoming_events_for_member,
effective_members, effective_members,
notify_newly_invited, notify_newly_invited,
player_attendance_rankings, player_attendance_rankings,
@@ -20,6 +21,7 @@ from .recurrence import (
__all__ = [ __all__ = [
"apply_template", "apply_template",
"blocked_upcoming_events_for_member",
"cancel_occurrence", "cancel_occurrence",
"detach_occurrence", "detach_occurrence",
"effective_members", "effective_members",

View File

@@ -22,7 +22,8 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext from django.utils.translation import ngettext
from club.models import ClubMembership, Season from club.models import ClubMembership, Season
from club.services.onboarding import blocked_member_ids_for_event from club.services.access import current_season
from club.services.onboarding import blocked_member_ids_for_event, open_requirements_blocking
from events.models import Attendance, Event from events.models import Attendance, Event
from members.models import Member from members.models import Member
from notifications.services import notify_members from notifications.services import notify_members
@@ -88,6 +89,50 @@ def sync_event_attendances(event):
event.attendances.filter(member_id__in=to_remove).delete() event.attendances.filter(member_id__in=to_remove).delete()
def blocked_upcoming_events_for_member(member, club):
"""Upcoming events ``member`` would normally see (via a team roster or a
group they're in) but has no ``Attendance`` row for, because an open
onboarding requirement blocks that event's kind -- the read side of
``effective_members()``'s own exclusion, which otherwise makes the event
vanish for them with no explanation at all. Built for the member-facing
"why can't I sign up" card (mobile/views.py) rather than the event simply
never appearing.
Returns ``[(event, [blocking OnboardingRequirement, ...]), ...]``, soonest
first. A member explicitly ``invited_members`` on an event never appears
here -- that bypasses blocking entirely (see ``effective_members``'s own
docstring), so they already have a normal Attendance row for it."""
season = current_season(club)
if season is None:
return []
team_ids = list(TeamMembership.objects.filter(member=member, season=season).values_list("team_id", flat=True))
group_ids = list(member.group_memberships.values_list("group_id", flat=True))
if not team_ids and not group_ids:
return []
candidates = list(
Event.objects.filter(club=club, cancelled=False, start__gte=timezone.now())
.filter(Q(teams__id__in=team_ids) | Q(groups__id__in=group_ids))
.exclude(excluded_members=member)
.distinct()
.order_by("start")
)
if not candidates:
return []
existing_ids = set(Attendance.objects.filter(member=member, event__in=candidates).values_list("event_id", flat=True))
results = []
for event in candidates:
if event.pk in existing_ids:
continue
requirements = open_requirements_blocking(member, club, season, event.kind)
if requirements:
results.append((event, requirements))
return results
def notify_newly_invited(member, *, club, events): def notify_newly_invited(member, *, club, events):
"""One notification (push+email), not one per event -- used when a """One notification (push+email), not one per event -- used when a
roster/group change (events/signals.py's sync_on_roster_change/ roster/group change (events/signals.py's sync_on_roster_change/

View File

@@ -21,6 +21,7 @@ from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment
from .admin import EventAdminForm from .admin import EventAdminForm
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup
from .services import ( from .services import (
blocked_upcoming_events_for_member,
cancel_occurrence, cancel_occurrence,
detach_occurrence, detach_occurrence,
effective_members, effective_members,
@@ -337,6 +338,68 @@ class EffectiveMembersTests(EventsTestBase):
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id}) self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
# --- blocked_upcoming_events_for_member (the read-side "why can't I sign up" mirror) ---
def test_blocked_event_is_reported_with_its_blocking_requirement(self):
self.make_membership(self.alice)
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
event = self.make_event(kind=Event.EventKind.GAME)
event.teams.set([self.team])
results = blocked_upcoming_events_for_member(self.alice, self.club)
self.assertEqual(len(results), 1)
blocked_event, requirements = results[0]
self.assertEqual(blocked_event, event)
self.assertEqual(requirements, [requirement])
def test_an_unblocked_member_reports_nothing(self):
# Bob has no ClubMembership row at all in the base fixture -- nothing
# for open_requirements_blocking to find, same as blocked_member_ids_
# for_event's own "no club membership" case above.
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
event = self.make_event(kind=Event.EventKind.GAME)
event.teams.set([self.team])
self.assertEqual(blocked_upcoming_events_for_member(self.bob, self.club), [])
def test_a_kind_the_requirement_does_not_block_reports_nothing(self):
self.make_membership(self.alice)
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
event = self.make_event(kind=Event.EventKind.TRAINING)
event.teams.set([self.team])
self.assertEqual(blocked_upcoming_events_for_member(self.alice, self.club), [])
def test_a_resolved_requirement_reports_nothing(self):
membership = self.make_membership(self.alice)
staff = get_user_model().objects.create_user(email="staff2@example.com", password="pw-secret-123")
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
mark_complete(membership, requirement, user=staff)
event = self.make_event(kind=Event.EventKind.GAME)
event.teams.set([self.team])
self.assertEqual(blocked_upcoming_events_for_member(self.alice, self.club), [])
def test_an_explicit_invite_is_not_reported_as_blocked(self):
# invited_members bypasses the block entirely -- Alice already has a
# normal Attendance row for this event, so there's nothing to explain.
self.make_membership(self.alice)
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
event = self.make_event(kind=Event.EventKind.GAME)
event.teams.set([self.team])
event.invited_members.set([self.alice])
self.assertEqual(blocked_upcoming_events_for_member(self.alice, self.club), [])
def test_a_past_blocked_event_is_not_reported(self):
self.make_membership(self.alice)
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
event = self.make_event(kind=Event.EventKind.GAME, start=timezone.now() - timedelta(days=1))
event.teams.set([self.team])
self.assertEqual(blocked_upcoming_events_for_member(self.alice, self.club), [])
class AttendanceSyncTests(EventsTestBase): class AttendanceSyncTests(EventsTestBase):
def test_setting_teams_creates_attendance_for_roster(self): def test_setting_teams_creates_attendance_for_roster(self):

View File

@@ -0,0 +1,40 @@
{% load i18n lucide %}
{% comment %}
One "can't sign up yet" row on M3's Calendar -- a game/practice a managed
person would normally be invited to (via their team/group), but isn't,
because an open onboarding requirement blocks that event's kind (club.
services.onboarding.open_requirements_blocking). Without this the event
would just silently never appear anywhere (events.services.attendance.
effective_members already excludes it from Attendance sync) -- shown
instead, muted, with which requirement is in the way, same "explain, don't
just hide" reasoning as the referee row's own distinct treatment.
Expects ``row`` ({event, blocked_requirements, blocked_member}) in scope --
blocked_member is only set once there's more than one managed person to
tell apart, same rule mobile/_calendar_row.html's own ``member`` uses.
No action here at all, not even a link styled as one -- there's nothing
to tap into doing, just an explanation, so it links straight to the event
like every other row rather than growing its own inert button.
{% endcomment %}
<a class="flex items-center gap-3 bg-white px-4 py-3 opacity-70" href="{% url "mobile:event_detail" row.event.pk %}">
<div class="w-9.5 shrink-0 text-center">
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ row.event.start|date:"D" }}</div>
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ row.event.start|date:"d" }}</div>
</div>
<div class="w-[3px] shrink-0 self-stretch rounded-full bg-line"></div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1 text-sm font-semibold text-ink">
{% lucide "lock" size=13 stroke_width=2.4 class="shrink-0 text-dim" %}
<span class="truncate">{{ row.event.title }}</span>
</div>
<div class="truncate text-xs text-muted">
{{ row.event.start|date:"H:i" }}
{% if row.blocked_member %}&middot; {{ row.blocked_member.first_name }}{% endif %}
{% for team in row.event.teams.all %}&middot; {{ team.name }}{% endfor %}
</div>
<div class="truncate text-xs text-club-dark">
{% blocktrans with name=row.blocked_requirements.0.name %}Complete “{{ name }}” to sign up{% endblocktrans %}
</div>
</div>
<span class="pill pill-neutral shrink-0">{% trans "Blocked" %}</span>
</a>

View File

@@ -8,14 +8,20 @@
docstring -- later_months is a list of {month_start, rows}). No person docstring -- later_months is a list of {month_start, rows}). No person
switcher and no club-wide toggle here -- always every event switcher and no club-wide toggle here -- always every event
self.managed_people is invited to, plus (merged into the same self.managed_people is invited to, plus (merged into the same
chronological list, own accent colour) any home game self.me is chronological list, each its own accent colour/look):
eligible to referee -- see mobile/_calendar_referee_row.html. A real, - any home game a managed person is eligible to referee -- see
working ?kind= mobile/_calendar_referee_row.html;
filter (All/Games/Practices) stands in for the design mock's "Games only" - any event a managed person would normally be invited to but can't sign
pill; its List/Month toggle isn't reproduced -- that's a second view up for yet, because an open onboarding requirement blocks it -- see
mode, not a filter. -mx-4 (on the row groups only, not this filter row) mobile/_calendar_blocked_row.html. Without this row it would just
breaks the rows out of base.html's shared page padding so they run silently never appear (events.services.attendance.effective_members
edge-to-edge, matching the design canvas's own M3 markup. already excludes it), with no explanation at all.
A real, working ?kind= filter (All/Games/Practices) stands in for the
design mock's "Games only" pill; its List/Month toggle isn't reproduced --
that's a second view mode, not a filter. -mx-4 (on the row groups only,
not this filter row) breaks the rows out of base.html's shared page
padding so they run edge-to-edge, matching the design canvas's own M3
markup.
{% endcomment %} {% endcomment %}
{% block header_extra %} {% block header_extra %}
@@ -63,7 +69,7 @@
<div class="bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div> <div class="bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div>
<div class="flex flex-col gap-px bg-line"> <div class="flex flex-col gap-px bg-line">
{% for row in this_week %} {% for row in this_week %}
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %} {% if row.blocked_requirements %}{% include "mobile/_calendar_blocked_row.html" %}{% elif row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -74,7 +80,7 @@
<div class="bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div> <div class="bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
<div class="flex flex-col gap-px bg-line"> <div class="flex flex-col gap-px bg-line">
{% for row in next_week %} {% for row in next_week %}
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %} {% if row.blocked_requirements %}{% include "mobile/_calendar_blocked_row.html" %}{% elif row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -85,7 +91,7 @@
<div class="bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{{ month.month_start|date:"F Y" }}</div> <div class="bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{{ month.month_start|date:"F Y" }}</div>
<div class="flex flex-col gap-px bg-line"> <div class="flex flex-col gap-px bg-line">
{% for row in month.rows %} {% for row in month.rows %}
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %} {% if row.blocked_requirements %}{% include "mobile/_calendar_blocked_row.html" %}{% elif row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@@ -212,6 +212,28 @@
</div> </div>
{% endif %} {% endif %}
{% if blocked_signups %}
<div class="m-card p-4">
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Can't sign up yet" %}</span>
<div class="mt-3 flex flex-col gap-3">
{% for row in blocked_signups %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<div class="flex items-center gap-2.5">
{% include "mobile/_avatar.html" with person=row.member size_class="h-8 w-8" %}
<div class="min-w-0 flex-1">
<div class="text-[15px] font-semibold text-ink">{{ row.member.get_full_name }}</div>
<div class="text-xs text-muted">
{% for requirement in row.requirements %}
{% blocktrans with name=requirement.name %}Complete “{{ name }}”{% endblocktrans %}{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if squad_summary %} {% if squad_summary %}
<div class="m-card p-4"> <div class="m-card p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -231,7 +253,7 @@
</div> </div>
{% endif %} {% endif %}
{% if not your_answers and not squad_summary and not lineup and not referee_signups %} {% if not your_answers and not squad_summary and not lineup and not referee_signups and not blocked_signups %}
<div class="m-card p-6 text-center"> <div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "No one you manage is invited to this event." %}</p> <p class="text-sm text-muted">{% trans "No one you manage is invited to this event." %}</p>
</div> </div>

View File

@@ -1007,6 +1007,39 @@ class CalendarViewTests(TestCase):
self.assertIn(event, self._events_in_context(response)) self.assertIn(event, self._events_in_context(response))
def test_a_blocked_event_shows_up_with_an_explanation_instead_of_disappearing(self):
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
position = Position.objects.create(club=self.club, name="Forward", short_name="F")
TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=position)
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
event = self.make_event(title="Cup game", kind=Event.EventKind.GAME)
event.teams.add(team)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Cup game")
self.assertContains(response, "Blocked")
self.assertContains(response, "Medical certificate")
self.assertFalse(Attendance.objects.filter(event=event, member=self.member).exists())
def test_a_resolved_requirement_shows_the_event_normally_not_blocked(self):
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
position = Position.objects.create(club=self.club, name="Forward", short_name="F")
TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=position)
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
club_membership = ClubMembership.objects.get(club=self.club, member=self.member, season=self.season)
MemberRequirementStatus.objects.create(membership=club_membership, requirement=requirement, is_complete=True)
event = self.make_event(title="Cup game", kind=Event.EventKind.GAME)
event.teams.add(team)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Cup game")
self.assertNotContains(response, "Blocked")
self.assertTrue(Attendance.objects.filter(event=event, member=self.member).exists())
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CalendarRefereeSignupTests(TestCase): class CalendarRefereeSignupTests(TestCase):
@@ -1338,6 +1371,32 @@ class EventDetailScreenTests(TestCase):
self.assertNotContains(response, 'name="status" value="dropout"') self.assertNotContains(response, 'name="status" value="dropout"')
def test_blocked_signup_shows_why_instead_of_disappearing(self):
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
game = Event.objects.create(club=self.club, title="Cup game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=7))
game.teams.add(self.team)
self.client.force_login(self.user)
response = self._get(game)
self.assertEqual(len(response.context["blocked_signups"]), 1)
self.assertContains(response, "Can't sign up yet")
self.assertContains(response, "Medical certificate")
self.assertFalse(Attendance.objects.filter(event=game, member=self.member).exists())
def test_no_blocked_card_once_the_requirement_is_resolved(self):
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
club_membership = ClubMembership.objects.get(club=self.club, member=self.member, season=self.season)
MemberRequirementStatus.objects.create(membership=club_membership, requirement=requirement, is_complete=True)
game = Event.objects.create(club=self.club, title="Cup game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=7))
game.teams.add(self.team)
self.client.force_login(self.user)
response = self._get(game)
self.assertEqual(response.context["blocked_signups"], [])
self.assertNotContains(response, "Can't sign up yet")
def test_no_referee_card_when_not_invited(self): def test_no_referee_card_when_not_invited(self):
self.client.force_login(self.user) self.client.force_login(self.user)

View File

@@ -24,10 +24,11 @@ from django.views.generic import TemplateView
from club.models import ClubMembership from club.models import ClubMembership
from club.services.access import current_season, has_management_access, teams_managed_by from club.services.access import current_season, has_management_access, teams_managed_by
from club.services.fees import open_dues_rows from club.services.fees import open_dues_rows
from club.services.onboarding import checklist_for from club.services.onboarding import checklist_for, open_requirements_blocking
from club.services.sponsors import active_sponsors from club.services.sponsors import active_sponsors
from controlpanel.messages import notify from controlpanel.messages import notify
from events.models import Attendance, Event, Lineup, RefereeSignup from events.models import Attendance, Event, Lineup, RefereeSignup
from events.services.attendance import blocked_upcoming_events_for_member
from events.services.calendar import week_bounds from events.services.calendar import week_bounds
from events.services.lineup import notify_dropout, selected_members_by_position from events.services.lineup import notify_dropout, selected_members_by_position
from events.services.referees import RefereeAssignmentError, accept_referee_signup, decline_referee_signup from events.services.referees import RefereeAssignmentError, accept_referee_signup, decline_referee_signup
@@ -373,6 +374,16 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
) )
rows += [{"event": signup.event, "referee_signup": signup, "referee_member": signup.member if show_member else None} for signup in signups] rows += [{"event": signup.event, "referee_signup": signup, "referee_member": signup.member if show_member else None} for signup in signups]
# A blocked event would otherwise just silently never appear (effective_
# members() already excludes it from Attendance sync) -- shown here
# instead, with which onboarding requirement is in the way, rather than
# a managed person's game quietly vanishing with no explanation at all.
for person in self.managed_people:
for event, requirements in blocked_upcoming_events_for_member(person, self.request.club):
if kind_filter in self.KIND_FILTERS and event.kind != self.KIND_FILTERS[kind_filter]:
continue
rows.append({"event": event, "blocked_requirements": requirements, "blocked_member": person if show_member else None})
rows.sort(key=lambda row: row["event"].start) rows.sort(key=lambda row: row["event"].start)
this_week, next_week, later_rows = [], [], [] this_week, next_week, later_rows = [], [], []
@@ -477,6 +488,7 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
referee_signups = list(RefereeSignup.objects.filter(event=event, member__in=self.managed_people, status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED]).select_related("member")) referee_signups = list(RefereeSignup.objects.filter(event=event, member__in=self.managed_people, status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED]).select_related("member"))
your_answers = [] your_answers = []
blocked_signups = []
if self.managed_people: if self.managed_people:
managed_ids = [person.pk for person in self.managed_people] managed_ids = [person.pk for person in self.managed_people]
attendances_by_member = {attendance.member_id: attendance for attendance in Attendance.objects.filter(event=event, member_id__in=managed_ids).select_related("member")} attendances_by_member = {attendance.member_id: attendance for attendance in Attendance.objects.filter(event=event, member_id__in=managed_ids).select_related("member")}
@@ -491,6 +503,14 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
for person in self.managed_people: for person in self.managed_people:
attendance = attendances_by_member.get(person.pk) attendance = attendances_by_member.get(person.pk)
if attendance is None: if attendance is None:
# No Attendance row at all -- either genuinely not invited, or
# excluded by an open onboarding requirement (events.services.
# attendance.effective_members). The latter still deserves an
# explanation here rather than just silently not showing up.
if season is not None:
requirements = open_requirements_blocking(person, self.request.club, season, event.kind)
if requirements:
blocked_signups.append({"member": person, "requirements": requirements})
continue continue
your_answers.append({"member": person, "attendance": attendance, "membership": memberships_by_member.get(person.pk)}) your_answers.append({"member": person, "attendance": attendance, "membership": memberships_by_member.get(person.pk)})
@@ -522,6 +542,7 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
lineup_categories=lineup_categories, lineup_categories=lineup_categories,
referee_signups=referee_signups, referee_signups=referee_signups,
your_answers=your_answers, your_answers=your_answers,
blocked_signups=blocked_signups,
squad_summary=squad_summary, squad_summary=squad_summary,
**kwargs, **kwargs,
) )

File diff suppressed because one or more lines are too long