New `mobile` Django app mounted at /app/ -- the installed PWA for Member mode (M1-M7, see design_handoff_rosterchief_platform/README.md). Coach mode (C1-C6) is a later phase and has no routes yet. Foundation pieces: - assets/mobile.css: Tailwind v4 theme reusing management.css's design tokens (same per-club --tenant-* theming pattern), plus the ice/coach accent and mobile's 14px card radius. - PushSubscription model + pywebpush-based sender (mobile/services/push.py), wired to notifications.Notification via a post_save signal so the existing notification system gains a push channel without knowing about PWAs itself. - Per-club manifest.webmanifest + service worker (served at /app/sw.js) + a server-rendered fallback home-screen icon (club initials on secondary_color) for clubs without an uploaded logo -- confirmed with the user as the fallback, never a generic RosterChief mark. - App shell (base.html): navy header, person switcher (every child a signed-in parent manages, plus "Me"), bottom tab bar, safe-area insets. Vendored htmx + Alpine for the screens built on top of it. - Placeholder views/routes for all seven M1-M7 screens so the shell is fully wired end-to-end before each screen is built out individually. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
157 lines
5.5 KiB
Python
157 lines
5.5 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.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
|
|
from django.template.loader import render_to_string
|
|
from django.urls import reverse
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.views import View
|
|
from django.views.generic import TemplateView
|
|
|
|
from members.models import Member
|
|
from members.views import ClubScopedPublicMixin
|
|
|
|
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(_PlaceholderScreen):
|
|
screen_title = _("Home")
|
|
active_tab = "home"
|
|
|
|
|
|
class CalendarView(_PlaceholderScreen):
|
|
screen_title = _("Calendar")
|
|
active_tab = "calendar"
|
|
|
|
|
|
class EventDetailView(_PlaceholderScreen):
|
|
screen_title = _("Event")
|
|
active_tab = "calendar"
|
|
|
|
|
|
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"
|