Hero card for the soonest upcoming event with a quick In/Out RSVP, a "needs your answer" list, a season-dues card, and a news teaser -- all scoped to the person switcher's current selection. The RSVP quick action posts to the (still placeholder-GET) event-detail URL that a later screen (M2) will build out fully. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
262 lines
10 KiB
Python
262 lines
10 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 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 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(_PlaceholderScreen):
|
|
screen_title = _("Calendar")
|
|
active_tab = "calendar"
|
|
|
|
|
|
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"
|