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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
40
mobile/templates/mobile/_calendar_blocked_row.html
Normal file
40
mobile/templates/mobile/_calendar_blocked_row.html
Normal 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 %}· {{ row.blocked_member.first_name }}{% endif %}
|
||||||
|
{% for team in row.event.teams.all %}· {{ 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user