Files
RosterChief/mobile/views.py
Bernard Siebens 29acd22b7f Build M3 Calendar for the mobile app
A bounded chronological agenda (this week / next week, no month paging --
the mobile screen doesn't need the desktop week/month grid
events.services.calendar was built for) scoped to the person switcher's
current selection, plus an "All members" toggle for the whole club's
schedule. Each row links into the still-placeholder-GET event-detail
screen that M2 builds out next.

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

328 lines
14 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 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(_PlaceholderScreen):
"""GET is still the M2 placeholder (a later screen owns the full detail
page); POST is M1's quick In/Out RSVP action, reused by any future screen
that posts the same {status, member_id} shape at this URL."""
screen_title = _("Event")
active_tab = "calendar"
def post(self, request, *args, **kwargs):
status = request.POST.get("status")
if status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT):
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})
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"