First slice of the coach-mode build (design_handoff_rosterchief_platform/ README.md's "Coach mode" section, C1-C6). Ships the foundation together with the two screens the design doc calls out as the reason coaches install anything at all, rather than landing a Today screen with a dead "Check attendance" button: - mobile/coach_mixins.py's CoachScopeMixin -- the dark-mode mirror of PersonScopeMixin, scoped by active team (via club.services.access' teams_staffed_by/teams_managed_by) instead of managed people. - A standalone dark ink/ice shell (mobile/templates/mobile/coach/base.html) with the mode's signature 20px-radius overlapping sheet, reusing the --color-ice/--color-ink tokens that already existed in assets/mobile.css but were unconsumed until now. - The Coach/Member role switcher is re-added to the member shell, gated on a real staff assignment (has_coach_access), replacing the "deliberately not rendered yet" placeholder. - C1 Today: stat tiles, a tonight's-session card, a silent-players "needs you" row, and an "Also yours" card reusing HomeView's own hero-RSVP pattern scoped to the coach's own member record. - C2 Bench attendance: writes through events.services.attendance. record_check_in, which existed for exactly this and had no caller yet. Read-only for staff on a team without a management position. Line-up (C3), create event (C4), post news (C5), and add-to-roster (C6) follow in later stages -- see the coach-mode plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
103 lines
5.5 KiB
Python
103 lines
5.5 KiB
Python
"""Shared scaffolding for every Member-mode screen (design_handoff_rosterchief_platform/
|
|
README.md: "there is no parent app... every per-member screen carries a person
|
|
switcher at the top" + "the [Coach/Member] switcher only renders for an account
|
|
holding >=1 staff role"). One mixin so M1-M7's views (and the subagents building
|
|
them) don't each re-derive this.
|
|
"""
|
|
|
|
from django.conf import settings
|
|
|
|
from club.services.access import current_season, has_management_access, teams_staffed_by
|
|
from members.models import FamilyMembership, Member
|
|
from members.views import ClubScopedPublicMixin
|
|
from notifications.models import Notification
|
|
|
|
|
|
class PersonScopeMixin(ClubScopedPublicMixin):
|
|
"""Resolves the signed-in account's own Member record plus every child
|
|
they're a parent/guardian of *in this club* (mirrors members.views.MyFamilyView's
|
|
own query -- kept separate rather than imported from there, since that view
|
|
is public/unauthenticated-reachable and this one is always behind login).
|
|
|
|
``?as=<member-id>`` re-scopes the current screen to one managed person,
|
|
same as the design doc's horizontally-scrolling chip row -- it re-scopes in
|
|
place rather than navigating. ``?as=all`` (or, once there's more than one
|
|
managed person, no ``?as=`` at all) scopes to *every* managed person --
|
|
that's the default the moment there's actually something to aggregate; a
|
|
member on their own, or a parent of exactly one child, has nothing to
|
|
aggregate and just lands on that one record, same as before this existed.
|
|
|
|
Screens that only ever operate on one person at a time keep using
|
|
``scope_person`` (``None`` in "everyone" mode); screens that can
|
|
meaningfully show several people at once (Home's cards, Calendar's own
|
|
"my schedule" listing) should use ``people_in_scope`` instead, which is
|
|
always the right list to filter by regardless of which mode is active.
|
|
"""
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
self.me = Member.objects.filter(user=request.user).first() if request.user.is_authenticated else None
|
|
self.managed_people = self._managed_people(request)
|
|
self.scope_everyone, self.scope_person = self._resolve_scope(request)
|
|
self.people_in_scope = self.managed_people if self.scope_everyone else ([self.scope_person] if self.scope_person else [])
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def _managed_people(self, request):
|
|
if self.me is None:
|
|
return []
|
|
children = list(
|
|
Member.objects.filter(
|
|
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
|
|
family_memberships__family__memberships__member=self.me,
|
|
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
|
|
member_of__club=request.club,
|
|
).distinct()
|
|
)
|
|
return [self.me, *children]
|
|
|
|
def _resolve_scope(self, request) -> tuple[bool, Member | None]:
|
|
"""Returns ``(scope_everyone, scope_person)`` -- exactly one of the
|
|
two is ever meaningful at a time (the other is ``False``/``None``)."""
|
|
requested_id = request.GET.get("as")
|
|
if requested_id and requested_id != "all":
|
|
for person in self.managed_people:
|
|
if str(person.pk) == requested_id:
|
|
return False, person
|
|
if requested_id == "all":
|
|
return True, None
|
|
if len(self.managed_people) > 1:
|
|
return True, None
|
|
return False, (self.managed_people[0] if self.managed_people else None)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
unread_notification_count = 0
|
|
if self.managed_people:
|
|
unread_notification_count = Notification.objects.filter(club=self.request.club, member__in=self.managed_people, read_at__isnull=True).count()
|
|
|
|
# Every screen sets these as class attributes (see e.g. HomeView.active_tab),
|
|
# but only a subclass that explicitly forwards them into its own
|
|
# get_context_data actually gets them into the template -- easy to forget
|
|
# (most screens' own get_context_data never touch either one), so default
|
|
# them here instead. setdefault, not an outright override: a screen with a
|
|
# dynamic title (EventDetailView's event.title) already passes its own
|
|
# screen_title through kwargs, and that must win.
|
|
kwargs.setdefault("active_tab", getattr(self, "active_tab", ""))
|
|
kwargs.setdefault("screen_title", getattr(self, "screen_title", ""))
|
|
|
|
return super().get_context_data(
|
|
me=self.me,
|
|
managed_people=self.managed_people,
|
|
scope_person=self.scope_person,
|
|
scope_everyone=self.scope_everyone,
|
|
has_staff_access=self.me is not None and has_management_access(self.request.user, self.request.club),
|
|
# Narrower than has_staff_access above: an ADMIN/EDITOR with no
|
|
# personal StaffAssignment satisfies that (desktop management
|
|
# access), but the design doc is explicit the Coach/Member
|
|
# switcher itself only appears for an account holding an actual
|
|
# staff assignment -- see mobile/coach_mixins.py's CoachScopeMixin.
|
|
has_coach_access=self.me is not None and teams_staffed_by(self.request.user, self.request.club).exists(),
|
|
unread_notification_count=unread_notification_count,
|
|
season=current_season(self.request.club),
|
|
vapid_public_key=settings.VAPID_PUBLIC_KEY,
|
|
**kwargs,
|
|
)
|