Referee sign-up (Calendar row and event detail's own card) was scoped to self.me only; a referee-eligible child is exactly as real as a referee- eligible parent, and a parent signing one up is no different from answering an RSVP on their behalf -- both surfaces and the respond view now cover every managed person, and the calendar row names whose invite it is once there's more than one managed person to tell apart. Event detail's single-card layout became a per-person list, same shape as "Your answers". Also hide the scrollbar on both mobile shells (the member shell's own document scroll, and the coach shell's still-inner-scrolling .coach-sheet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
957 lines
47 KiB
Python
957 lines
47 KiB
Python
"""Member-mode screens (M1-M7) plus the PWA plumbing (manifest, service worker,
|
|
icon, push subscribe) they all sit on top of. Coach mode (C1-C6) is a later
|
|
phase -- see design_handoff_rosterchief_platform/README.md -- and has no
|
|
routes here yet.
|
|
"""
|
|
|
|
import datetime
|
|
import itertools
|
|
import json
|
|
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.core.paginator import Paginator
|
|
from django.db.models import Count, Q
|
|
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
|
|
from django.shortcuts import get_object_or_404
|
|
from django.template.loader import render_to_string
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django.utils.translation import get_language
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.views import View
|
|
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.sponsors import active_sponsors
|
|
from controlpanel.messages import notify
|
|
from events.models import Attendance, Event, Lineup, RefereeSignup
|
|
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
|
|
from members.models import FamilyMembership, Member
|
|
from members.views import ClubScopedPublicMixin
|
|
from news.models import News
|
|
from notifications.models import Notification
|
|
from teams.models import StaffAssignment, TeamMembership
|
|
|
|
from .forms import MemberProfileForm
|
|
from .mixins import PersonScopeMixin
|
|
from .models import CalendarFeedToken, PushSubscription
|
|
from .services.calendar_feed import build_feed
|
|
from .services.icons import render_fallback_icon
|
|
|
|
|
|
class ManifestView(ClubScopedPublicMixin, View):
|
|
"""Per-club web-app manifest -- the club's own logo (or its server-rendered
|
|
initials, see services/icons.py) becomes the home-screen icon, never a
|
|
generic RosterChief mark."""
|
|
|
|
def get(self, request):
|
|
club = request.club
|
|
manifest = {
|
|
"name": club.name,
|
|
"short_name": club.name[:24],
|
|
"start_url": "/app/",
|
|
"scope": "/app/",
|
|
"display": "standalone",
|
|
"background_color": "#101e36",
|
|
"theme_color": club.primary_color or "#101e36",
|
|
"icons": [
|
|
{"src": reverse("mobile:icon", kwargs={"size": 192}), "sizes": "192x192", "purpose": "any"},
|
|
{"src": reverse("mobile:icon", kwargs={"size": 512}), "sizes": "512x512", "purpose": "any"},
|
|
],
|
|
}
|
|
return JsonResponse(manifest, content_type="application/manifest+json")
|
|
|
|
|
|
class AppIconView(ClubScopedPublicMixin, View):
|
|
def get(self, request, size):
|
|
club = request.club
|
|
if club.logo:
|
|
return HttpResponseRedirect(club.logo.url)
|
|
return HttpResponse(render_fallback_icon(club, size=size), content_type="image/png")
|
|
|
|
|
|
class ServiceWorkerView(View):
|
|
"""Served literally at /app/sw.js (mobile/urls.py), not under /static/ --
|
|
a service worker's default scope is everything below the path it's served
|
|
from, so this is what makes the worker's scope /app/ without an explicit
|
|
Service-Worker-Allowed header."""
|
|
|
|
def get(self, request):
|
|
return HttpResponse(render_to_string("mobile/sw.js", {}), content_type="application/javascript")
|
|
|
|
|
|
class PushSubscribeView(LoginRequiredMixin, ClubScopedPublicMixin, View):
|
|
"""Called by mobile/static/mobile/app.js once the browser hands back a
|
|
PushSubscription. Keyed on endpoint (globally unique per browser
|
|
registration, see PushSubscription's own docstring): re-subscribing the
|
|
same browser updates its row instead of creating a duplicate."""
|
|
|
|
def post(self, request):
|
|
member = Member.objects.filter(user=request.user).first()
|
|
if member is None:
|
|
return HttpResponseBadRequest("No member record for this account.")
|
|
|
|
try:
|
|
payload = json.loads(request.body)
|
|
endpoint = payload["endpoint"]
|
|
p256dh = payload["keys"]["p256dh"]
|
|
auth = payload["keys"]["auth"]
|
|
except (KeyError, TypeError, ValueError):
|
|
return HttpResponseBadRequest("Malformed subscription payload.")
|
|
|
|
PushSubscription.objects.update_or_create(
|
|
endpoint=endpoint,
|
|
defaults={
|
|
"club": request.club,
|
|
"member": member,
|
|
"p256dh": p256dh,
|
|
"auth": auth,
|
|
"user_agent": request.META.get("HTTP_USER_AGENT", "")[:255],
|
|
},
|
|
)
|
|
return JsonResponse({"status": "ok"})
|
|
|
|
def delete(self, request):
|
|
try:
|
|
endpoint = json.loads(request.body)["endpoint"]
|
|
except (KeyError, TypeError, ValueError):
|
|
return HttpResponseBadRequest("Malformed unsubscribe payload.")
|
|
PushSubscription.objects.filter(endpoint=endpoint).delete()
|
|
return JsonResponse({"status": "ok"})
|
|
|
|
|
|
class CalendarFeedView(ClubScopedPublicMixin, View):
|
|
"""The .ics subscription feed a calendar app polls -- URL-token
|
|
authenticated, not LoginRequiredMixin (a calendar app can't do
|
|
interactive login). See mobile.services.calendar_feed.build_feed for the
|
|
feed's own shape/scope, and CalendarFeedToken's own docstring for why one
|
|
token covers every club the account belongs to."""
|
|
|
|
def get(self, request, token):
|
|
feed_token = get_object_or_404(CalendarFeedToken.objects.select_related("user"), token=token)
|
|
me = Member.objects.filter(user=feed_token.user).first()
|
|
people = []
|
|
if me is not None:
|
|
children = Member.objects.filter(
|
|
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
|
|
family_memberships__family__memberships__member=me,
|
|
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
|
|
member_of__club=request.club,
|
|
).distinct()
|
|
people = [me, *children]
|
|
|
|
feed = build_feed(request.club, people)
|
|
return HttpResponse(feed, content_type="text/calendar; charset=utf-8")
|
|
|
|
|
|
class CalendarFeedSettingsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""M5's "Calendar sync" row -- the webcal://.../https:// subscription
|
|
links for this account's combined feed, plus a reset action that
|
|
immediately invalidates the old URL (e.g. a link shared/leaked by
|
|
accident)."""
|
|
|
|
template_name = "mobile/calendar_feed_settings.html"
|
|
screen_title = _("Calendar sync")
|
|
active_tab = "me"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
feed_token, _created = CalendarFeedToken.objects.get_or_create(user=self.request.user)
|
|
feed_url = self.request.build_absolute_uri(reverse("mobile:calendar_feed", kwargs={"token": feed_token.token}))
|
|
return super().get_context_data(feed_url=feed_url, webcal_url=feed_url.replace("https://", "webcal://", 1).replace("http://", "webcal://", 1), **kwargs)
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
feed_token, _created = CalendarFeedToken.objects.get_or_create(user=request.user)
|
|
feed_token.regenerate()
|
|
title = _("Calendar link reset")
|
|
body = _("Your old calendar link no longer works. Re-subscribe using the new one below.")
|
|
notify(request, f"s|{title}|{body}")
|
|
return HttpResponseRedirect(reverse("mobile:calendar_feed_settings"))
|
|
|
|
|
|
class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
|
|
hero card for the soonest upcoming event across everyone currently in
|
|
scope (with a quick In/Out RSVP -- see EventDetailView.post below), a
|
|
"needs your answer" list of upcoming events still NO_RESPONSE/MAYBE, a
|
|
season-dues card per person who owes money, and a news teaser. Every
|
|
card is independently optional -- an empty-state screen is just the
|
|
four `if`s below all being falsy.
|
|
|
|
Scoped to ``self.people_in_scope`` (mobile/mixins.py), not just
|
|
``scope_person`` -- with the header's "All" chip now the default the
|
|
moment there's more than one managed person, this screen aggregates
|
|
across everyone in that case rather than showing only one person's
|
|
cards.
|
|
"""
|
|
|
|
template_name = "mobile/home.html"
|
|
screen_title = _("Home")
|
|
active_tab = "home"
|
|
|
|
#: Keeps the card from crowding the dues/news cards below it off the first
|
|
#: screenful -- Calendar is the place to see everything still awaiting a reply.
|
|
NEEDS_ANSWER_LIMIT = 5
|
|
#: Same reasoning -- mobile:news_list is the place to see everything.
|
|
NEWS_LIMIT = 3
|
|
|
|
def get_context_data(self, **kwargs):
|
|
people = self.people_in_scope
|
|
now = timezone.now()
|
|
|
|
hero_attendance = None
|
|
rsvp_closed = False
|
|
needs_answer = []
|
|
needs_answer_total = 0
|
|
dues_rows = []
|
|
news_items = []
|
|
|
|
if people:
|
|
upcoming = Attendance.objects.filter(
|
|
member__in=people,
|
|
event__club=self.request.club,
|
|
event__cancelled=False,
|
|
event__start__gte=now,
|
|
).select_related("event", "event__location", "member")
|
|
|
|
hero_attendance = upcoming.order_by("event__start").first()
|
|
if hero_attendance is not None:
|
|
deadline = hero_attendance.event.deadline
|
|
rsvp_closed = deadline is not None and deadline < now
|
|
|
|
# Deadline already passed -> replying is no longer possible (see
|
|
# EventDetailView.post's own deadline check), so it doesn't belong
|
|
# in a "still needs a reply" list -- unlike hero_attendance above,
|
|
# which always shows the true next event regardless of RSVP state
|
|
# and falls back to a read-only pill once its own deadline closes.
|
|
needs_answer_qs = (
|
|
upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE])
|
|
.filter(Q(event__deadline__isnull=True) | Q(event__deadline__gte=now))
|
|
.order_by("event__start")
|
|
)
|
|
if hero_attendance is not None:
|
|
needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk)
|
|
needs_answer_total = needs_answer_qs.count()
|
|
needs_answer = list(needs_answer_qs[: self.NEEDS_ANSWER_LIMIT])
|
|
|
|
season = current_season(self.request.club)
|
|
if season is not None:
|
|
dues_rows = open_dues_rows(self.request.club, people, season)
|
|
# Deliberately self.managed_people, not the scoped `people` above --
|
|
# news isn't a per-person action like RSVP/dues, it's "things this
|
|
# account should know about", so a parent picking their own "Me"
|
|
# chip (no team of their own) still sees their kids' team news
|
|
# rather than only club-wide items.
|
|
news_team_ids = list(TeamMembership.objects.filter(member__in=self.managed_people, season=season).values_list("team_id", flat=True))
|
|
else:
|
|
news_team_ids = []
|
|
|
|
news_items = list(
|
|
News.objects.filter(
|
|
club=self.request.club,
|
|
status=News.Status.PUBLISHED,
|
|
published_at__lte=now,
|
|
visibility__in=[News.Visibility.INTERNAL, News.Visibility.BOTH],
|
|
)
|
|
.filter(Q(teams__isnull=True) | Q(teams__id__in=news_team_ids))
|
|
.prefetch_related("teams")
|
|
.order_by("-published_at")
|
|
.distinct()[: self.NEWS_LIMIT]
|
|
)
|
|
|
|
return super().get_context_data(
|
|
hero_attendance=hero_attendance,
|
|
rsvp_closed=rsvp_closed,
|
|
needs_answer=needs_answer,
|
|
needs_answer_remaining=max(needs_answer_total - len(needs_answer), 0),
|
|
dues_rows=dues_rows,
|
|
news_items=news_items,
|
|
# Club-wide, not person-specific -- shown regardless of managed_people,
|
|
# unlike every other card on this screen. Reshuffled on every request
|
|
# (see club.services.sponsors.active_sponsors) rather than once per
|
|
# session, same as the public-website sponsor strip it shares logic with.
|
|
sponsors=active_sponsors(self.request.club, randomize=True),
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""M3 -- README's M3 section: a chronological agenda list (not the desktop
|
|
week/month grid events.services.calendar was built for) grouped under
|
|
"This week"/"Next week", then everything further out grouped by calendar
|
|
month (a "later_months" list of {month_start, rows}, each its own sticky
|
|
header) -- an unbounded agenda rather than a fixed lookahead window, since
|
|
a hard cutoff just hid events a member would reasonably expect to still
|
|
find here. No ?month= paging beyond that grouping -- there's no season
|
|
browser here, just "everything upcoming, readably grouped".
|
|
|
|
Always scoped to every one of ``self.managed_people`` -- unlike Home,
|
|
this screen has no person switcher and no "every club event" toggle: it's
|
|
just "what is my family invited to", full stop. A ``?kind=`` filter (All/
|
|
Games/Practices, the two dominant event kinds) narrows that down -- a
|
|
real, working version of the design mock's own "Games only" pill, not
|
|
reproducing its List/Month toggle (that needs a whole second view mode,
|
|
not a filter) or its per-person "All members" (removed on purpose, see
|
|
above).
|
|
"""
|
|
|
|
template_name = "mobile/calendar.html"
|
|
screen_title = _("Calendar")
|
|
active_tab = "calendar"
|
|
|
|
#: ?kind= values this screen actually understands, mapped to the Event.EventKind
|
|
#: they filter on -- anything else (including no param at all) means "All".
|
|
KIND_FILTERS = {"game": Event.EventKind.GAME, "training": Event.EventKind.TRAINING}
|
|
|
|
#: Pill styling for a per-person RSVP status (assets/mobile.css's .pill-*).
|
|
STATUS_PILL_CLASSES = {
|
|
Attendance.AttendanceStatus.PRESENT: "pill-ok",
|
|
Attendance.AttendanceStatus.SELECTED: "pill-ok",
|
|
Attendance.AttendanceStatus.ABSENT: "pill-danger",
|
|
Attendance.AttendanceStatus.NOT_SELECTED: "pill-neutral",
|
|
Attendance.AttendanceStatus.EXCUSED: "pill-neutral",
|
|
Attendance.AttendanceStatus.MAYBE: "pill-warn",
|
|
Attendance.AttendanceStatus.NO_RESPONSE: "pill-warn",
|
|
}
|
|
|
|
def get_context_data(self, **kwargs):
|
|
now = timezone.now()
|
|
today = timezone.localdate()
|
|
_this_week_start, this_week_end = week_bounds(today)
|
|
next_week_end = this_week_end + datetime.timedelta(days=7)
|
|
kind_filter = self.request.GET.get("kind")
|
|
|
|
rows = []
|
|
if self.managed_people:
|
|
attendances = (
|
|
Attendance.objects.filter(
|
|
member__in=self.managed_people,
|
|
event__club=self.request.club,
|
|
event__cancelled=False,
|
|
event__start__gte=now,
|
|
)
|
|
.select_related("event", "event__location", "event__opponent", "member")
|
|
.prefetch_related("event__teams")
|
|
.order_by("event__start")
|
|
)
|
|
if kind_filter in self.KIND_FILTERS:
|
|
attendances = attendances.filter(event__kind=self.KIND_FILTERS[kind_filter])
|
|
|
|
# Only worth naming whose row it is once there's more than one managed
|
|
# person to tell apart -- a lone member's own agenda doesn't need it.
|
|
show_member = len(self.managed_people) > 1
|
|
rows = [
|
|
{"event": attendance.event, "pill_class": self.STATUS_PILL_CLASSES.get(attendance.status, "pill-neutral"), "pill_label": attendance.get_status_display(), "member": attendance.member if show_member else None}
|
|
for attendance in attendances
|
|
]
|
|
|
|
# Referee sign-ups are scoped to every managed person, same as the
|
|
# RSVP rows above -- a referee-eligible child is exactly as real as
|
|
# a referee-eligible parent, and a parent signing a kid up to
|
|
# referee is no different from answering an RSVP on their behalf.
|
|
# Merged into the same chronological list (own dict shape) rather
|
|
# than a separate section, so it reads on the actual day it falls
|
|
# on -- distinct styling is what sets it apart (mobile/
|
|
# _calendar_referee_row.html), not a different place on the screen.
|
|
# Declined invites are dropped; accepted ones stay visible as a
|
|
# confirmed commitment.
|
|
if self.managed_people and kind_filter != "training":
|
|
signups = (
|
|
RefereeSignup.objects.filter(
|
|
member__in=self.managed_people,
|
|
event__club=self.request.club,
|
|
event__cancelled=False,
|
|
event__start__gte=now,
|
|
status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED],
|
|
)
|
|
.select_related("event", "event__location", "event__opponent", "member")
|
|
.prefetch_related("event__teams")
|
|
)
|
|
rows += [{"event": signup.event, "referee_signup": signup, "referee_member": signup.member if show_member else None} for signup in signups]
|
|
|
|
rows.sort(key=lambda row: row["event"].start)
|
|
|
|
this_week, next_week, later_rows = [], [], []
|
|
for row in rows:
|
|
event_date = timezone.localtime(row["event"].start).date()
|
|
if event_date <= this_week_end:
|
|
this_week.append(row)
|
|
elif event_date <= next_week_end:
|
|
next_week.append(row)
|
|
else:
|
|
later_rows.append(row)
|
|
|
|
# ``rows`` is already start-ordered, so a plain groupby (no sorting) is
|
|
# enough to split later_rows into one chronological run per month.
|
|
later_months = [
|
|
{"month_start": month_start, "rows": list(month_rows)}
|
|
for month_start, month_rows in itertools.groupby(later_rows, key=lambda row: timezone.localtime(row["event"].start).date().replace(day=1))
|
|
]
|
|
|
|
return super().get_context_data(this_week=this_week, next_week=next_week, later_months=later_months, kind_filter=kind_filter if kind_filter in self.KIND_FILTERS else "", **kwargs)
|
|
|
|
|
|
class RefereeSignupRespondView(PersonScopeMixin, LoginRequiredMixin, View):
|
|
"""Accept/decline a referee invite, for self.me or any managed person
|
|
(a referee-eligible child is exactly as real as a referee-eligible
|
|
parent) -- from a Calendar row (mobile/_calendar_referee_row.html) or
|
|
the same event's own detail page (event_detail.html's own "Refereeing"
|
|
card, for the same signup). Routes through events.services.referees.
|
|
accept_referee_signup/decline_referee_signup, so capacity is enforced
|
|
in the one place the desktop admin flow already enforces it, and the
|
|
referee-management screen sees the result with no separate sync step.
|
|
Boosted (no explicit hx-boost="false") -- unlike event_detail's own
|
|
RSVP forms, nothing here is Alpine-owned/toggled, so there's no
|
|
htmx/Alpine conflict to dodge."""
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
signup = get_object_or_404(RefereeSignup, pk=kwargs["signup_id"], member__in=self.managed_people, event__club=request.club)
|
|
response = request.POST.get("response")
|
|
|
|
if response == "accept":
|
|
try:
|
|
accept_referee_signup(signup)
|
|
body = _("%(name)s is confirmed to referee -- see you there.") % {"name": signup.member.get_full_name()}
|
|
notify(request, f"s|{_('Referee sign-up confirmed')}|{body}")
|
|
except RefereeAssignmentError as exc:
|
|
notify(request, f"e|{_('Could not sign up')}|{exc}")
|
|
elif response == "decline":
|
|
decline_referee_signup(signup)
|
|
body = _("%(name)s won't be refereeing this one -- thanks for letting us know.") % {"name": signup.member.get_full_name()}
|
|
notify(request, f"s|{_('Declined')}|{body}")
|
|
else:
|
|
return HttpResponseBadRequest(_("Unknown response."))
|
|
|
|
if request.POST.get("next") == "event_detail":
|
|
return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": signup.event_id}))
|
|
return HttpResponseRedirect(reverse("mobile:calendar"))
|
|
|
|
|
|
class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""M2 -- design_handoff_rosterchief_platform/README.md's M2 section:
|
|
"answer for several". A hero header (no event photos in this codebase --
|
|
a solid dark block instead, like M1's hero card), an event-detail card
|
|
(Face-off/Meet/Where -- the design mock's extra "Kit" row and dressing-room
|
|
detail have no backing Event field, so they're simply not shown), a
|
|
per-managed-person RSVP card scoped to ``self.managed_people`` who
|
|
actually have an ``Attendance`` row for this event (i.e. are invited --
|
|
not every managed person necessarily is), and a club/team-visible
|
|
squad-response aggregate (counts only, never who-answered-what) shown
|
|
only when the event has an actual team roster to aggregate.
|
|
|
|
POST is still M1's quick In/Out RSVP action (now also accepting "maybe"
|
|
for M2's three-way buttons), reused by any screen that posts the same
|
|
{status, member_id} shape at this URL. An optional ``next=event_detail``
|
|
field redirects back here instead of Home -- M2's own forms send it so a
|
|
parent answering for several people in a row sees each update land
|
|
without bouncing away; M1's/M3's existing forms don't send it, so they
|
|
keep redirecting to Home unchanged.
|
|
"""
|
|
|
|
template_name = "mobile/event_detail.html"
|
|
screen_title = _("Event")
|
|
active_tab = "calendar"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
event = get_object_or_404(Event.objects.select_related("location", "opponent"), pk=self.kwargs["pk"], club=self.request.club)
|
|
season = event.season or current_season(self.request.club)
|
|
rsvp_closed = event.deadline is not None and event.deadline < timezone.now()
|
|
# A published line-up supersedes ordinary RSVP -- the roster's locked
|
|
# in, so "Your answers" below switches to read-only and the line-up
|
|
# itself gets its own card.
|
|
lineup = Lineup.objects.filter(event=event, published_at__isnull=False).first()
|
|
lineup_categories = selected_members_by_position(lineup) if lineup is not None else []
|
|
|
|
# Same managed_people scope as the Calendar row this mirrors (mobile/
|
|
# _calendar_referee_row.html) -- a referee-eligible child is exactly
|
|
# as real as a referee-eligible parent. Declined signups are
|
|
# excluded -- nothing left to do. Rare in practice (a game usually
|
|
# has one eligible referee per family, if any), so this is a plain
|
|
# list rather than the "Your answers" card's counts-only aggregate.
|
|
referee_signups = []
|
|
if self.managed_people:
|
|
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 = []
|
|
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")}
|
|
|
|
memberships_by_member = {}
|
|
if season is not None:
|
|
memberships_by_member = {
|
|
membership.member_id: membership
|
|
for membership in TeamMembership.objects.filter(member_id__in=managed_ids, team__in=event.teams.all(), season=season).select_related("team", "position")
|
|
}
|
|
|
|
for person in self.managed_people:
|
|
attendance = attendances_by_member.get(person.pk)
|
|
if attendance is None:
|
|
continue
|
|
your_answers.append({"member": person, "attendance": attendance, "membership": memberships_by_member.get(person.pk)})
|
|
|
|
squad_summary = None
|
|
if event.teams.exists():
|
|
counts = Attendance.objects.filter(event=event).aggregate(
|
|
in_count=Count("id", filter=Q(status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED])),
|
|
out_count=Count("id", filter=Q(status__in=[Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.NOT_SELECTED])),
|
|
no_reply_count=Count("id", filter=Q(status__in=[Attendance.AttendanceStatus.MAYBE, Attendance.AttendanceStatus.NO_RESPONSE])),
|
|
)
|
|
total = counts["in_count"] + counts["out_count"] + counts["no_reply_count"]
|
|
if total:
|
|
squad_summary = {
|
|
"total": total,
|
|
"responded": counts["in_count"] + counts["out_count"],
|
|
"in_count": counts["in_count"],
|
|
"out_count": counts["out_count"],
|
|
"no_reply_count": counts["no_reply_count"],
|
|
"in_pct": round(100 * counts["in_count"] / total),
|
|
"out_pct": round(100 * counts["out_count"] / total),
|
|
"no_reply_pct": round(100 * counts["no_reply_count"] / total),
|
|
}
|
|
|
|
return super().get_context_data(
|
|
screen_title=event.title,
|
|
event=event,
|
|
rsvp_closed=rsvp_closed,
|
|
lineup=lineup,
|
|
lineup_categories=lineup_categories,
|
|
referee_signups=referee_signups,
|
|
your_answers=your_answers,
|
|
squad_summary=squad_summary,
|
|
**kwargs,
|
|
)
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
status = request.POST.get("status")
|
|
# "dropout" isn't an AttendanceStatus -- it's this same form posting
|
|
# from the "Can't make it after all" button a SELECTED member sees
|
|
# once the line-up's published (event_detail.html). It resolves to
|
|
# ABSENT below, same as an ordinary Out, but skips the closed-deadline
|
|
# guard (the line-up is published well after most deadlines) and
|
|
# additionally pings the event's managers, since by this point only
|
|
# they can still act on it (swap the slot, warn the opponent, ...).
|
|
is_dropout = status == "dropout"
|
|
if not is_dropout and status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.MAYBE):
|
|
return HttpResponseBadRequest(_("Unknown RSVP status."))
|
|
|
|
# Every current caller (Home's hero, M2's per-person rows) always sends an
|
|
# explicit member_id -- this is just a defensive fallback for one that doesn't.
|
|
fallback_member = self.scope_person or self.me
|
|
member_id = request.POST.get("member_id") or (str(fallback_member.pk) if fallback_member else None)
|
|
member = next((person for person in self.managed_people if str(person.pk) == member_id), None) if member_id else None
|
|
if member is None:
|
|
return HttpResponseBadRequest(_("You can't RSVP for that person."))
|
|
|
|
event = get_object_or_404(Event, pk=kwargs["pk"], club=request.club)
|
|
existing = Attendance.objects.filter(event=event, member=member).first()
|
|
|
|
if is_dropout:
|
|
if existing is None or existing.status != Attendance.AttendanceStatus.SELECTED:
|
|
return HttpResponseBadRequest(_("You're not in the published line-up for this event."))
|
|
# Mirrors the read-only treatment Home's hero and this same screen's own
|
|
# "Your answers" card already show once the deadline has passed (see
|
|
# get_context_data's rsvp_closed) -- enforced here too, since a disabled
|
|
# button in the UI is only a hint, not a guarantee against a direct POST.
|
|
elif event.deadline is not None and event.deadline < timezone.now():
|
|
return HttpResponseBadRequest(_("Replies are closed for this event."))
|
|
|
|
# A reason is only ever meaningful attached to Out/Maybe -- clearing it
|
|
# the moment someone flips to In avoids a stale "sick" note hanging
|
|
# around under an answer it no longer explains. Private by
|
|
# construction, not by a visibility flag: nothing renders another
|
|
# member's own note anywhere -- only this member/family's own screens
|
|
# (event_detail's "Your answers") and Coach mode's bench attendance
|
|
# (mobile/templates/mobile/coach/attendance.html) ever read it.
|
|
note = ""
|
|
if is_dropout or status == Attendance.AttendanceStatus.ABSENT:
|
|
note = request.POST.get("note", "").strip()
|
|
# Rejects blank and punctuation-only "answers" (a bare ".", "-",
|
|
# "??") -- mandatory for Out/dropout specifically, unlike Maybe
|
|
# below. Backend-scoped, not the pretty inline-error UX this
|
|
# codebase gives ModelForm submissions elsewhere -- matches this
|
|
# view's own existing style (see "Unknown RSVP status"/"Replies
|
|
# are closed" above, both plain 400s a normal user should never
|
|
# actually see, since the template only ever offers Out through
|
|
# the reason form to begin with).
|
|
if not any(char.isalnum() for char in note):
|
|
return HttpResponseBadRequest(_("Please enter a reason."))
|
|
elif status == Attendance.AttendanceStatus.MAYBE:
|
|
# Optional here -- Maybe doesn't owe anyone an explanation the way
|
|
# a firm no does, but the same field carries it if given one.
|
|
note = request.POST.get("note", "").strip()
|
|
|
|
final_status = Attendance.AttendanceStatus.ABSENT if is_dropout else status
|
|
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": final_status, "note": note})
|
|
if is_dropout:
|
|
notify_dropout(event, member, note)
|
|
|
|
if request.POST.get("next") == "event_detail":
|
|
return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": event.pk}))
|
|
return HttpResponseRedirect(reverse("mobile:home"))
|
|
|
|
|
|
class NewsListView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
""""All news" -- what Home's own news card links to once there's more than
|
|
NEWS_LIMIT items to show. Unlike Home's own teaser (team-relevant items
|
|
only), this is the full browsable archive: every published, member-visible
|
|
news item for the club, newest first, regardless of which team it's about.
|
|
Same visibility rule as Home -- internal or both, never external-only
|
|
(that's the public website's own audience, not this app's)."""
|
|
|
|
template_name = "mobile/news_list.html"
|
|
screen_title = _("News")
|
|
active_tab = "news"
|
|
PAGE_SIZE = 20
|
|
|
|
def get_context_data(self, **kwargs):
|
|
news_items = (
|
|
News.objects.filter(
|
|
club=self.request.club,
|
|
status=News.Status.PUBLISHED,
|
|
published_at__lte=timezone.now(),
|
|
visibility__in=[News.Visibility.INTERNAL, News.Visibility.BOTH],
|
|
)
|
|
.prefetch_related("teams")
|
|
.order_by("-published_at")
|
|
)
|
|
page = Paginator(news_items, self.PAGE_SIZE).get_page(self.request.GET.get("page"))
|
|
return super().get_context_data(page=page, **kwargs)
|
|
|
|
|
|
class NewsDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""M4 -- design_handoff_rosterchief_platform/README.md's M4 section: a
|
|
photo-hero permalink for a single published News item. Visibility mirrors
|
|
news.tasks.notify_news_published's own gate -- PUBLISHED *and* actually
|
|
live (published_at in the past) -- so a scheduled-but-not-yet-live item
|
|
404s here exactly like it does everywhere else a member could reach it,
|
|
rather than leaking a preview of it early.
|
|
|
|
Language is Django's own active-language detection, not a member-facing
|
|
toggle (unlike management's split-view Dutch/English editing tool) --
|
|
English shows only when the request's active language actually is "en";
|
|
everything else (including no active language at all) shows the native
|
|
(Dutch) text.
|
|
"""
|
|
|
|
template_name = "mobile/news_detail.html"
|
|
screen_title = _("News")
|
|
active_tab = "news"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
news_item = get_object_or_404(
|
|
News.objects.prefetch_related("teams", "photos"),
|
|
club=self.request.club,
|
|
slug=self.kwargs["slug"],
|
|
status=News.Status.PUBLISHED,
|
|
published_at__lte=timezone.now(),
|
|
)
|
|
|
|
if get_language() == "en":
|
|
title, body = news_item.effective_title_en, news_item.effective_body_en
|
|
else:
|
|
title, body = news_item.title, news_item.body
|
|
|
|
return super().get_context_data(
|
|
screen_title=title,
|
|
news_item=news_item,
|
|
article_title=title,
|
|
article_body=body,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
class MeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me
|
|
& my people". A header for ``self.me`` (member-since year plus a staff
|
|
label, see below), a "People I manage" card (one row per managed_people,
|
|
each with their current-season team + jersey number when they're on a
|
|
roster), and a settings-ish card linking into M6 (edit_profile) for
|
|
``self.me`` and into M7 (notifications).
|
|
|
|
The mockup's "Household & contacts" row and its "Coach mode" promo card
|
|
have nowhere to lead in this build (no dedicated screen) and are
|
|
deliberately omitted rather than built as dead or inert links.
|
|
"Payments & dues" does lead somewhere -- PaymentsView below -- with its
|
|
"N OPEN" pill only rendered once there's actually a balance owed.
|
|
|
|
"Teams I coach/manage" is new, beyond the mockup: one row per current-season
|
|
StaffAssignment self.me holds (team + position/role), each linking
|
|
straight into Coach mode for that team (mobile:coach_today?team=<pk>,
|
|
which mobile.coach_mixins.CoachScopeMixin's own ?team= handling already
|
|
resolves and persists). Shown for any staffed team, not just ones
|
|
self.me *manages* -- Coach mode's own screens already render read-only
|
|
for a non-management position, so there's nothing to hide here.
|
|
|
|
There's no license/eligibility field on Member or ClubMembership to power
|
|
the mockup's "licence OK" text, so each managed person's meta line is
|
|
real roster data instead: current-season team + jersey number when
|
|
they're on one, nothing extra otherwise.
|
|
"""
|
|
|
|
template_name = "mobile/me.html"
|
|
screen_title = _("Me")
|
|
active_tab = "me"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
club = self.request.club
|
|
season = current_season(club)
|
|
|
|
member_since = None
|
|
team_manager_label = None
|
|
|
|
if self.me is not None and season is not None:
|
|
membership = ClubMembership.objects.filter(club=club, member=self.me, season=season).first()
|
|
if membership is not None:
|
|
member_since = membership.created
|
|
|
|
# PersonScopeMixin.get_context_data computes this same check for the
|
|
# ``has_staff_access`` context var, but only as a context value, not
|
|
# an attribute on self -- recomputed here since it's needed before
|
|
# that runs.
|
|
if self.me is not None and has_management_access(self.request.user, club):
|
|
# teams_managed_by returns *every* club team for an ADMIN (see its
|
|
# own docstring) -- fine for authority checks, but a wall of team
|
|
# names makes a poor header subtitle, so it only gets spelled out
|
|
# for someone managing a small, specific handful; anyone else
|
|
# (including a full club admin) just reads as "Staff".
|
|
managed_teams = list(teams_managed_by(self.request.user, club))
|
|
if 1 <= len(managed_teams) <= 2:
|
|
team_manager_label = _("Team manager %(teams)s") % {"teams": ", ".join(sorted(team.short_name for team in managed_teams))}
|
|
else:
|
|
team_manager_label = _("Staff")
|
|
|
|
people_rows = []
|
|
if self.managed_people:
|
|
memberships_by_member = {}
|
|
if season is not None:
|
|
memberships_by_member = {
|
|
membership.member_id: membership
|
|
for membership in TeamMembership.objects.filter(member__in=self.managed_people, season=season).select_related("team")
|
|
}
|
|
people_rows = [{"person": person, "membership": memberships_by_member.get(person.pk)} for person in self.managed_people]
|
|
|
|
open_dues_count = len(open_dues_rows(club, self.managed_people, season))
|
|
|
|
staff_assignments = []
|
|
if self.me is not None and season is not None:
|
|
staff_assignments = list(StaffAssignment.objects.filter(member=self.me, season=season).select_related("team", "position").order_by("team__name"))
|
|
|
|
return super().get_context_data(
|
|
member_since=member_since,
|
|
team_manager_label=team_manager_label,
|
|
people_rows=people_rows,
|
|
open_dues_count=open_dues_count,
|
|
staff_assignments=staff_assignments,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
class PaymentsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""M5's "Payments & dues" row -- every open season-dues balance across
|
|
``self.managed_people``, one card per person owed, in the same €-badge /
|
|
amount+due-date / Pay layout as Home's dues card (club.services.fees.
|
|
open_dues_rows is the shared source for both). No online payment gateway
|
|
exists yet, so "Pay" is the same non-functional stub as Home's -- this
|
|
screen's job is visibility ("what do I owe, and when"), not collection.
|
|
"""
|
|
|
|
template_name = "mobile/payments.html"
|
|
screen_title = _("Payments & dues")
|
|
active_tab = "me"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
season = current_season(self.request.club)
|
|
dues_rows = open_dues_rows(self.request.club, self.managed_people, season)
|
|
return super().get_context_data(dues_rows=dues_rows, **kwargs)
|
|
|
|
|
|
class EditProfileView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""M6 -- design_handoff_rosterchief_platform/README.md's M6 section,
|
|
"Edit personal info". The design mock also shows a "National register
|
|
no.", an "Address", an "Allergies / notes" field and two "Consent"
|
|
toggles (photos on club channels / share contact with team parents) --
|
|
none of those exist on ``members.models.Member``, so (per this build's
|
|
"no schema changes for a screen-building pass" rule) they're simply not
|
|
part of this screen; MemberProfileForm (mobile/forms.py) only covers the
|
|
fields the model actually has.
|
|
|
|
Two more mock rows *do* have real backing data, both rendered read-only
|
|
(never editable here -- family links and staff document review each live
|
|
elsewhere in the platform, not on a member's own edit-info screen):
|
|
- "Contact 1" becomes every one of ``Member.guardians`` (there can be
|
|
more than one, unlike the mock's single row), each with its real
|
|
FamilyMembership role (parent/guardian/other) rather than the mock's
|
|
invented "mother".
|
|
- The "Medical form missing" banner becomes a real, club-defined
|
|
open-requirements list from club.services.onboarding.checklist_for,
|
|
scoped to this person's *current-season* ClubMembership -- shown only
|
|
when at least one active requirement is neither complete nor
|
|
bypassed (an "informational" open requirement with no current-season
|
|
membership at all just means the banner never renders).
|
|
|
|
Authorization: the target Member (``member_id`` URL kwarg) must be one of
|
|
``self.managed_people`` -- anyone else 404s, on both GET and POST. A 404
|
|
(not EventDetailView.post's 400) is the deliberate choice here: that 400
|
|
is for a malformed *value* inside an otherwise-valid POST to a resource
|
|
the requester can already see (the event); this is a different resource
|
|
per person, named directly in the URL, so an unmanaged member should read
|
|
as "no such page" exactly like Event/News already do for another club's
|
|
objects elsewhere in this file, not as a submission-shaped error.
|
|
"""
|
|
|
|
template_name = "mobile/edit_profile.html"
|
|
screen_title = _("Edit info")
|
|
active_tab = "me"
|
|
|
|
def _target_member(self):
|
|
member_id = str(self.kwargs["member_id"])
|
|
member = next((person for person in self.managed_people if str(person.pk) == member_id), None)
|
|
if member is None:
|
|
raise Http404("You can't edit that profile.")
|
|
return member
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
member = self._target_member()
|
|
form = MemberProfileForm(instance=member)
|
|
return self.render_to_response(self.get_context_data(member=member, form=form))
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
member = self._target_member()
|
|
form = MemberProfileForm(request.POST, instance=member)
|
|
if form.is_valid():
|
|
form.save()
|
|
title = _("Saved")
|
|
body = _("%(name)s's info was updated.") % {"name": member.get_full_name()}
|
|
notify(request, f"s|{title}|{body}")
|
|
return HttpResponseRedirect(reverse("mobile:me"))
|
|
return self.render_to_response(self.get_context_data(member=member, form=form))
|
|
|
|
def get_context_data(self, **kwargs):
|
|
member = kwargs["member"]
|
|
|
|
guardian_rows = [
|
|
{"member": family_membership.member, "role": family_membership.get_role_display()}
|
|
for family_membership in FamilyMembership.objects.filter(
|
|
role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
|
|
family__memberships__member=member,
|
|
family__memberships__role=FamilyMembership.FamilyRole.CHILD,
|
|
)
|
|
.select_related("member")
|
|
.distinct()
|
|
]
|
|
|
|
open_requirement_names = []
|
|
season = current_season(self.request.club)
|
|
if season is not None:
|
|
membership = ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first()
|
|
if membership is not None:
|
|
open_requirement_names = [requirement.name for requirement, status in checklist_for(membership) if status is None or not (status.is_complete or status.is_bypassed)]
|
|
|
|
return super().get_context_data(
|
|
screen_title=member.get_full_name(),
|
|
guardian_rows=guardian_rows,
|
|
open_requirement_names=open_requirement_names,
|
|
open_requirement_summary=", ".join(open_requirement_names),
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def _notification_source_link(source):
|
|
"""(label, url) for a notification's ``source`` when it's something this
|
|
app has a detail page for -- (None, None) otherwise. One place both
|
|
NotificationsView.get_context_data (the row's own label) and .post (the
|
|
tap-to-mark-read redirect target) read from, so a new source type only
|
|
needs adding here."""
|
|
if isinstance(source, News):
|
|
return _("Club news"), reverse("mobile:news_detail", kwargs={"slug": source.slug})
|
|
if isinstance(source, Event):
|
|
return _("Event"), reverse("mobile:event_detail", kwargs={"pk": source.pk})
|
|
return None, None
|
|
|
|
|
|
class NotificationsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
"""M7 -- design_handoff_rosterchief_platform/README.md's M7 section
|
|
("Inbox"). The mockup shows rich per-type cards (RSVP-needed, medical
|
|
form missing, invoice due, line-up published, ...) with inline quick
|
|
actions, but notifications.models.Notification is generic -- title/body/
|
|
created/read_at plus an optional ``source`` -- and the only things that
|
|
create member-facing rows today are news.tasks.notify_news_published and
|
|
events.tasks.notify_new_event. There's no type/category field to key a
|
|
richer layout or the mockup's "Action"/"Club" filter off, so this is
|
|
deliberately a flat, generic list: day-grouped ("Today"/"Earlier this
|
|
week"/"Older", echoing Calendar's own "This week"/"Next week" bucketing)
|
|
with an unread treatment and a link to the underlying News/Event when
|
|
``source`` resolves to one (see _notification_source_link above).
|
|
|
|
Scoped to every one of ``self.managed_people`` (not just scope_person) --
|
|
same scope PersonScopeMixin.get_context_data already uses for
|
|
unread_notification_count, since notifications aren't really "per
|
|
switched person" the way RSVPs are.
|
|
"""
|
|
|
|
template_name = "mobile/notifications.html"
|
|
screen_title = _("Notifications")
|
|
active_tab = "me"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
today = []
|
|
earlier_this_week = []
|
|
older = []
|
|
|
|
if self.managed_people:
|
|
this_week_start, _this_week_end = week_bounds(timezone.localdate())
|
|
local_today = timezone.localdate()
|
|
|
|
notifications = Notification.objects.filter(club=self.request.club, member__in=self.managed_people).select_related("member").order_by("-created")
|
|
|
|
for notification in notifications:
|
|
source_label, _url = _notification_source_link(notification.source)
|
|
row = {"notification": notification, "source_label": source_label}
|
|
|
|
created_date = timezone.localtime(notification.created).date()
|
|
if created_date == local_today:
|
|
today.append(row)
|
|
elif created_date >= this_week_start:
|
|
earlier_this_week.append(row)
|
|
else:
|
|
older.append(row)
|
|
|
|
return super().get_context_data(today=today, earlier_this_week=earlier_this_week, older=older, **kwargs)
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
action = request.POST.get("action")
|
|
|
|
if action == "mark_all_read":
|
|
Notification.objects.filter(club=request.club, member__in=self.managed_people, read_at__isnull=True).update(read_at=timezone.now())
|
|
return HttpResponseRedirect(reverse("mobile:notifications"))
|
|
|
|
if action == "clear_all":
|
|
# A hard delete, not another read-state flip -- "Clear" empties the
|
|
# list itself, same as the clear-all gesture in a phone's own
|
|
# notification centre, rather than just marking everything read.
|
|
Notification.objects.filter(club=request.club, member__in=self.managed_people).delete()
|
|
return HttpResponseRedirect(reverse("mobile:notifications"))
|
|
|
|
if action == "mark_read":
|
|
notification = Notification.objects.filter(pk=request.POST.get("notification_id"), club=request.club, member__in=self.managed_people).first()
|
|
if notification is None:
|
|
return HttpResponseBadRequest(_("You can't mark that notification as read."))
|
|
if notification.read_at is None:
|
|
notification.read_at = timezone.now()
|
|
notification.save(update_fields=["read_at", "modified"])
|
|
|
|
# A row whose source resolves to something this app has a detail
|
|
# page for doubles as a link to it (see _notification_source_link)
|
|
# -- the plain-POST tap that marks it read also lands the member
|
|
# there, no separate "next" field needed since the server already
|
|
# has the source.
|
|
_label, url = _notification_source_link(notification.source)
|
|
return HttpResponseRedirect(url or reverse("mobile:notifications"))
|
|
|
|
return HttpResponseBadRequest(_("Unknown action."))
|