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

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

View File

@@ -22,7 +22,8 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
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 members.models import Member
from notifications.services import notify_members
@@ -88,6 +89,50 @@ def sync_event_attendances(event):
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):
"""One notification (push+email), not one per event -- used when a
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 .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup
from .services import (
blocked_upcoming_events_for_member,
cancel_occurrence,
detach_occurrence,
effective_members,
@@ -337,6 +338,68 @@ class EffectiveMembersTests(EventsTestBase):
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):
def test_setting_teams_creates_attendance_for_roster(self):