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

@@ -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
switcher and no club-wide toggle here -- always every event
self.managed_people is invited to, plus (merged into the same
chronological list, own accent colour) any home game self.me is
eligible to referee -- see mobile/_calendar_referee_row.html. 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.
chronological list, each its own accent colour/look):
- any home game a managed person is eligible to referee -- see
mobile/_calendar_referee_row.html;
- any event a managed person would normally be invited to but can't sign
up for yet, because an open onboarding requirement blocks it -- see
mobile/_calendar_blocked_row.html. Without this row it would just
silently never appear (events.services.attendance.effective_members
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 %}
{% 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="flex flex-col gap-px bg-line">
{% 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 %}
</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="flex flex-col gap-px bg-line">
{% 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 %}
</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="flex flex-col gap-px bg-line">
{% 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 %}
</div>
</div>

View File

@@ -212,6 +212,28 @@
</div>
{% 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 %}
<div class="m-card p-4">
<div class="flex items-center justify-between">
@@ -231,7 +253,7 @@
</div>
{% 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">
<p class="text-sm text-muted">{% trans "No one you manage is invited to this event." %}</p>
</div>

View File

@@ -1007,6 +1007,39 @@ class CalendarViewTests(TestCase):
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"])
class CalendarRefereeSignupTests(TestCase):
@@ -1338,6 +1371,32 @@ class EventDetailScreenTests(TestCase):
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):
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.services.access import current_season, has_management_access, teams_managed_by
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 controlpanel.messages import notify
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.lineup import notify_dropout, selected_members_by_position
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]
# 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)
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"))
your_answers = []
blocked_signups = []
if 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")}
@@ -491,6 +503,14 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
for person in self.managed_people:
attendance = attendances_by_member.get(person.pk)
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
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,
referee_signups=referee_signups,
your_answers=your_answers,
blocked_signups=blocked_signups,
squad_summary=squad_summary,
**kwargs,
)