Files
RosterChief/mobile/views.py
Bernard Siebens 13f369e10c Build M2 Event detail ("answer for several") for the mobile app
Full event-detail screen: hero header, Face-off/Meet/Where facts (the
design mock's Kit/dressing-room details have no backing Event field, so
they're simply omitted), a per-managed-person RSVP card so a parent with
several kids on the same event can answer for each independently, and a
club-visible squad-response aggregate (counts only, never who answered
what).

Extends the existing quick-RSVP POST (built for M1's Home hero) to also
accept "maybe" for M2's three-way buttons, and adds an optional
next=event_detail redirect target so answering here doesn't bounce back
to Home -- M1's and M3's existing forms are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 13:44:53 +02:00

392 lines
17 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.
The M1-M7 views below are placeholders: each renders a "coming soon" card
inside the real app shell (base.html), at its final URL name, so the shell
(header, role switcher, tab bar, person switcher) can be verified end-to-end
before every screen is built out one at a time.
"""
import datetime
import json
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
from django.http import 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 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
from club.services.fees import remaining_balance
from events.models import Attendance, Event
from events.services.calendar import week_bounds
from members.models import Member
from members.views import ClubScopedPublicMixin
from news.models import News
from teams.models import TeamMembership
from .mixins import PersonScopeMixin
from .models import PushSubscription
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 _PlaceholderScreen(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""Stand-in for an M-screen not built yet. Each subclass below is replaced
entirely -- view and template -- when its screen is built; only the URL
name/path in mobile/urls.py needs to stay put."""
template_name = "mobile/_placeholder.html"
screen_title = ""
active_tab = ""
def get_context_data(self, **kwargs):
return super().get_context_data(screen_title=self.screen_title, active_tab=self.active_tab, **kwargs)
class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
hero card for scope_person's soonest upcoming event (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 when money is
owed, and a news teaser. Every card is independently optional -- an
empty-state screen is just the four `if`s below all being falsy.
"""
template_name = "mobile/home.html"
screen_title = _("Home")
active_tab = "home"
def get_context_data(self, **kwargs):
scope_person = self.scope_person
now = timezone.now()
hero_attendance = None
rsvp_closed = False
needs_answer = []
dues_membership = None
dues_balance = None
dues_invoice = None
news_item = None
if scope_person is not None:
upcoming = Attendance.objects.filter(
member=scope_person,
event__club=self.request.club,
event__cancelled=False,
event__start__gte=now,
).select_related("event", "event__location")
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
needs_answer_qs = upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE]).order_by("event__start")
if hero_attendance is not None:
needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk)
needs_answer = list(needs_answer_qs)
season = current_season(self.request.club)
if season is not None:
membership = (
ClubMembership.objects.filter(club=self.request.club, member=scope_person, season=season)
.exclude(fee_status=ClubMembership.FeeStatus.WAIVED)
.select_related("dues_invoice")
.first()
)
if membership is not None:
balance = remaining_balance(membership)
if balance > 0:
dues_membership = membership
dues_balance = balance
dues_invoice = getattr(membership, "dues_invoice", None)
team_ids = list(TeamMembership.objects.filter(member=scope_person, season=season).values_list("team_id", flat=True))
else:
team_ids = []
news_item = (
News.objects.filter(club=self.request.club, status=News.Status.PUBLISHED, published_at__lte=now)
.filter(Q(teams__isnull=True) | Q(teams__id__in=team_ids))
.order_by("-published_at")
.distinct()
.first()
)
return super().get_context_data(
hero_attendance=hero_attendance,
rsvp_closed=rsvp_closed,
needs_answer=needs_answer,
dues_membership=dues_membership,
dues_balance=dues_balance,
dues_invoice=dues_invoice,
news_item=news_item,
news_team=news_item.teams.first() if news_item is not None else None,
**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". Browsing-window judgment call: the design doc
doesn't specify month navigation for the mobile screen, so this only ever
shows *upcoming* events across the current and next calendar week (no
"Later"/past bucket, no ?month= paging) -- a simple, bounded agenda rather
than a full season browser.
``?scope=all`` is the design doc's extra "All members" scope on top of
the normal per-person chip switcher (mobile/mixins.py's scope_person):
every club event instead of just scope_person's own invites, since
there's no single person's Attendance row to key off.
"""
template_name = "mobile/calendar.html"
screen_title = _("Calendar")
active_tab = "calendar"
#: 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):
scope_all = self.request.GET.get("scope") == "all"
now = timezone.now()
_this_week_start, this_week_end = week_bounds(timezone.localdate())
next_week_end = this_week_end + datetime.timedelta(days=7)
window_end = timezone.make_aware(datetime.datetime.combine(next_week_end, datetime.time.max))
rows = []
if scope_all:
events = (
Event.objects.filter(club=self.request.club, cancelled=False, start__gte=now, start__lte=window_end)
.select_related("location", "opponent")
.prefetch_related("teams")
.order_by("start")
)
rows = [{"event": event, "pill_class": "pill-info", "pill_label": event.get_kind_display()} for event in events]
elif self.scope_person is not None:
attendances = (
Attendance.objects.filter(
member=self.scope_person,
event__club=self.request.club,
event__cancelled=False,
event__start__gte=now,
event__start__lte=window_end,
)
.select_related("event", "event__location", "event__opponent")
.prefetch_related("event__teams")
.order_by("event__start")
)
rows = [{"event": attendance.event, "pill_class": self.STATUS_PILL_CLASSES.get(attendance.status, "pill-neutral"), "pill_label": attendance.get_status_display()} for attendance in attendances]
this_week, next_week = [], []
for row in rows:
bucket = this_week if timezone.localtime(row["event"].start).date() <= this_week_end else next_week
bucket.append(row)
return super().get_context_data(scope_all=scope_all, this_week=this_week, next_week=next_week, **kwargs)
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)
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, your_answers=your_answers, squad_summary=squad_summary, **kwargs)
def post(self, request, *args, **kwargs):
status = request.POST.get("status")
if status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.MAYBE):
return HttpResponseBadRequest(_("Unknown RSVP status."))
member_id = request.POST.get("member_id") or (str(self.scope_person.pk) if self.scope_person 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)
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status})
if request.POST.get("next") == "event_detail":
return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": event.pk}))
return HttpResponseRedirect(reverse("mobile:home"))
class NewsDetailView(_PlaceholderScreen):
screen_title = _("News")
active_tab = "news"
class MeView(_PlaceholderScreen):
screen_title = _("Me")
active_tab = "me"
class EditProfileView(_PlaceholderScreen):
screen_title = _("Edit info")
active_tab = "me"
class NotificationsView(_PlaceholderScreen):
screen_title = _("Notifications")
active_tab = "me"