Scaffold the mobile member app: PWA shell, push, and app-shell tokens
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
This commit is contained in:
0
mobile/__init__.py
Normal file
0
mobile/__init__.py
Normal file
11
mobile/admin.py
Normal file
11
mobile/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import PushSubscription
|
||||
|
||||
|
||||
@admin.register(PushSubscription)
|
||||
class PushSubscriptionAdmin(admin.ModelAdmin):
|
||||
list_display = ["member", "club", "user_agent", "created"]
|
||||
list_filter = ["club"]
|
||||
search_fields = ["member__first_name", "member__last_name", "endpoint"]
|
||||
autocomplete_fields = ["member"]
|
||||
8
mobile/apps.py
Normal file
8
mobile/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MobileConfig(AppConfig):
|
||||
name = "mobile"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
37
mobile/migrations/0001_initial.py
Normal file
37
mobile/migrations/0001_initial.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-21 08:23
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('club', '0029_duesinvoice'),
|
||||
('members', '0006_parentclaim_submitted_by_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PushSubscription',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('endpoint', models.URLField(max_length=500, unique=True, verbose_name='endpoint')),
|
||||
('p256dh', models.CharField(max_length=255, verbose_name='p256dh key')),
|
||||
('auth', models.CharField(max_length=255, verbose_name='auth key')),
|
||||
('user_agent', models.CharField(blank=True, max_length=255, verbose_name='user agent')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='push_subscriptions', to='members.member', verbose_name='member')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'push subscription',
|
||||
'verbose_name_plural': 'push subscriptions',
|
||||
'ordering': ['-created'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
mobile/migrations/__init__.py
Normal file
0
mobile/migrations/__init__.py
Normal file
69
mobile/mixins.py
Normal file
69
mobile/mixins.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Shared scaffolding for every Member-mode screen (design_handoff_rosterchief_platform/
|
||||
README.md: "there is no parent app... every per-member screen carries a person
|
||||
switcher at the top" + "the [Coach/Member] switcher only renders for an account
|
||||
holding >=1 staff role"). One mixin so M1-M7's views (and the subagents building
|
||||
them) don't each re-derive this.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from club.services.access import current_season, has_management_access
|
||||
from members.models import FamilyMembership, Member
|
||||
from members.views import ClubScopedPublicMixin
|
||||
from notifications.models import Notification
|
||||
|
||||
|
||||
class PersonScopeMixin(ClubScopedPublicMixin):
|
||||
"""Resolves the signed-in account's own Member record plus every child
|
||||
they're a parent/guardian of *in this club* (mirrors members.views.MyFamilyView's
|
||||
own query -- kept separate rather than imported from there, since that view
|
||||
is public/unauthenticated-reachable and this one is always behind login).
|
||||
|
||||
``?as=<member-id>`` re-scopes the current screen to one managed person,
|
||||
same as the design doc's horizontally-scrolling chip row -- it re-scopes in
|
||||
place rather than navigating. Falls back to the account's own Member, then
|
||||
to the first managed child (e.g. a parent with no Member record of their own).
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.me = Member.objects.filter(user=request.user).first() if request.user.is_authenticated else None
|
||||
self.managed_people = self._managed_people(request)
|
||||
self.scope_person = self._resolve_scope_person(request)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def _managed_people(self, request):
|
||||
if self.me is None:
|
||||
return []
|
||||
children = list(
|
||||
Member.objects.filter(
|
||||
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
|
||||
family_memberships__family__memberships__member=self.me,
|
||||
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
|
||||
member_of__club=request.club,
|
||||
).distinct()
|
||||
)
|
||||
return [self.me, *children]
|
||||
|
||||
def _resolve_scope_person(self, request):
|
||||
requested_id = request.GET.get("as")
|
||||
if requested_id:
|
||||
for person in self.managed_people:
|
||||
if str(person.pk) == requested_id:
|
||||
return person
|
||||
return self.managed_people[0] if self.managed_people else None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
unread_notification_count = 0
|
||||
if self.managed_people:
|
||||
unread_notification_count = Notification.objects.filter(club=self.request.club, member__in=self.managed_people, read_at__isnull=True).count()
|
||||
|
||||
return super().get_context_data(
|
||||
me=self.me,
|
||||
managed_people=self.managed_people,
|
||||
scope_person=self.scope_person,
|
||||
has_staff_access=self.me is not None and has_management_access(self.request.user, self.request.club),
|
||||
unread_notification_count=unread_notification_count,
|
||||
season=current_season(self.request.club),
|
||||
vapid_public_key=settings.VAPID_PUBLIC_KEY,
|
||||
**kwargs,
|
||||
)
|
||||
38
mobile/models.py
Normal file
38
mobile/models.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from members.models import Member
|
||||
from rosterchief.base import ClubScopedModel, validate_club_scope
|
||||
|
||||
|
||||
class PushSubscription(ClubScopedModel):
|
||||
"""One browser/device's Web Push registration for one member -- created by
|
||||
the subscribe flow in mobile/static/mobile/app.js, sent to whenever
|
||||
mobile.signals pushes a new notifications.Notification for that member.
|
||||
|
||||
`endpoint` (the URL the browser's own push service gave it) is globally
|
||||
unique by construction -- a browser only ever has one push registration
|
||||
per origin -- so it doubles as the natural "already subscribed on this
|
||||
device" key: re-subscribing replaces the row rather than creating a
|
||||
second one for the same browser (see mobile.views.PushSubscribeView).
|
||||
"""
|
||||
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="push_subscriptions", verbose_name=_("member"))
|
||||
endpoint = models.URLField(_("endpoint"), max_length=500, unique=True)
|
||||
p256dh = models.CharField(_("p256dh key"), max_length=255)
|
||||
auth = models.CharField(_("auth key"), max_length=255)
|
||||
user_agent = models.CharField(_("user agent"), max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("push subscription")
|
||||
verbose_name_plural = _("push subscriptions")
|
||||
ordering = ["-created"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.member} — {self.user_agent or self.endpoint[:40]}"
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.club_id, member_fields=("member",))
|
||||
|
||||
def as_subscription_info(self) -> dict:
|
||||
return {"endpoint": self.endpoint, "keys": {"p256dh": self.p256dh, "auth": self.auth}}
|
||||
0
mobile/services/__init__.py
Normal file
0
mobile/services/__init__.py
Normal file
36
mobile/services/icons.py
Normal file
36
mobile/services/icons.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Server-rendered fallback PWA icon: a club's initials on its own
|
||||
secondary_color -- confirmed with the user as the fallback for a club that
|
||||
hasn't uploaded a logo, rather than a shared RosterChief mark (Club.initials'
|
||||
own docstring: "Never the RosterChief mark -- that would pass our branding
|
||||
off as the club's own", same reasoning applied to the home-screen icon).
|
||||
|
||||
Rendered on request by mobile.views.AppIconView, not stored -- a club's
|
||||
colours/initials change rarely enough that regenerating a small PNG per
|
||||
request is cheaper than adding cache invalidation for it.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
_DEFAULT_BACKGROUND = "#e4002b"
|
||||
_DEFAULT_FOREGROUND = "#ffffff"
|
||||
|
||||
|
||||
def render_fallback_icon(club, size: int = 512) -> bytes:
|
||||
background = club.secondary_color or _DEFAULT_BACKGROUND
|
||||
foreground = club.secondary_content_color or _DEFAULT_FOREGROUND
|
||||
|
||||
image = Image.new("RGB", (size, size), background)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
initials = club.initials or "RC"
|
||||
font = ImageFont.load_default(size=int(size * 0.42))
|
||||
|
||||
left, top, right, bottom = draw.textbbox((0, 0), initials, font=font)
|
||||
text_width, text_height = right - left, bottom - top
|
||||
draw.text(((size - text_width) / 2 - left, (size - text_height) / 2 - top), initials, font=font, fill=foreground)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
48
mobile/services/push.py
Normal file
48
mobile/services/push.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Web Push delivery -- the push channel for notifications.Notification.
|
||||
|
||||
Wired in from mobile.signals (a post_save on Notification), deliberately kept
|
||||
out of the notifications app itself, which stays channel-agnostic (see that
|
||||
app's models.py docstring: "the whole point is reusing this for other kinds
|
||||
of activity later"). A backward dependency the other way -- notifications
|
||||
importing mobile -- would be the wrong direction: notifications has no
|
||||
reason to know a PWA exists.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from pywebpush import WebPushException, webpush
|
||||
|
||||
from .. import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_push_to_member(member, *, title: str, body: str, url: str = "/app/") -> None:
|
||||
if not settings.VAPID_PRIVATE_KEY:
|
||||
# Dev/no-config default -- see the settings.py comment next to VAPID_PRIVATE_KEY.
|
||||
return
|
||||
|
||||
payload = json.dumps({"title": title, "body": body, "url": url})
|
||||
for subscription in models.PushSubscription.objects.filter(member=member):
|
||||
try:
|
||||
webpush(
|
||||
subscription_info=subscription.as_subscription_info(),
|
||||
data=payload,
|
||||
vapid_private_key=settings.VAPID_PRIVATE_KEY,
|
||||
vapid_claims={"sub": f"mailto:{settings.VAPID_ADMIN_EMAIL}"},
|
||||
)
|
||||
except WebPushException as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
if status in (404, 410):
|
||||
# The browser's push service says this registration is gone for good --
|
||||
# not a transient failure, so keeping it around would only mean retrying
|
||||
# a subscription that will never accept a push again.
|
||||
subscription.delete()
|
||||
else:
|
||||
logger.warning("Push send failed for %s: %s", member, exc)
|
||||
except OSError as exc:
|
||||
# Never fatal -- same reasoning as every other branded send in this app
|
||||
# (see e.g. notifications.services._send_email).
|
||||
logger.warning("Push send failed for %s: %s", member, exc)
|
||||
13
mobile/signals.py
Normal file
13
mobile/signals.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from notifications.models import Notification
|
||||
|
||||
from .services.push import send_push_to_member
|
||||
|
||||
|
||||
@receiver(post_save, sender=Notification)
|
||||
def push_new_notification(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
send_push_to_member(instance.member, title=instance.title, body=instance.body)
|
||||
9
mobile/templates/mobile/_placeholder.html
Normal file
9
mobile/templates/mobile/_placeholder.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "mobile/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="m-card p-6 text-center">
|
||||
<p class="font-display text-lg font-bold text-ink uppercase">{{ screen_title }}</p>
|
||||
<p class="mt-1 text-sm text-muted">{% trans "This screen is coming soon." %}</p>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
122
mobile/templates/mobile/base.html
Normal file
122
mobile/templates/mobile/base.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% load static i18n %}
|
||||
|
||||
{% comment %}
|
||||
App shell for Member mode (M1-M7) -- design_handoff_rosterchief_platform/README.md.
|
||||
Standalone, like management/base.html and controlpanel/base.html: its own
|
||||
stylesheet (assets/mobile.css), no daisyUI, no shared nav. Mobile-first,
|
||||
viewport-fit=cover + env(safe-area-inset-*) throughout (see mobile.css's own
|
||||
file banner) rather than the iOS bezel in the design canvas, which is
|
||||
presentation-only.
|
||||
|
||||
The Coach/Member role switcher described in the design doc is deliberately
|
||||
NOT rendered yet: it would switch into a mode with no screens built (Coach
|
||||
mode is an explicitly separate, later phase). Re-add it here once C1-C6 ship.
|
||||
{% endcomment %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE_CODE|default:"en" }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="{{ club.primary_color|default:"#101e36" }}">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
|
||||
<title>{% block title %}{{ screen_title }} · {{ club.name }}{% endblock title %}</title>
|
||||
|
||||
<link rel="manifest" href="{% url "mobile:manifest" %}">
|
||||
<link rel="apple-touch-icon" href="{% url "mobile:icon" size=192 %}">
|
||||
<link rel="icon" href="{% url "mobile:icon" size=192 %}">
|
||||
<link rel="stylesheet" href="{% static "css/mobile.css" %}">
|
||||
|
||||
{% if club.secondary_color %}
|
||||
<style>
|
||||
:root {
|
||||
--tenant-club: {{ club.secondary_color }};
|
||||
--tenant-club-dark: color-mix(in srgb, {{ club.secondary_color }} 80%, black);
|
||||
--tenant-club-content: {{ club.secondary_content_color }};
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
{% if club.primary_color %}
|
||||
<style>
|
||||
:root { --tenant-navy: {{ club.primary_color }}; }
|
||||
</style>
|
||||
{% endif %}
|
||||
|
||||
<script src="{% static "js/htmx.js" %}" defer></script>
|
||||
<script src="{% static "js/alpine.js" %}" defer></script>
|
||||
|
||||
{% block extra_head %}{% endblock extra_head %}
|
||||
</head>
|
||||
|
||||
<body class="flex h-screen flex-col overflow-hidden bg-paper font-sans text-slate" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' data-vapid-public-key="{{ vapid_public_key }}" data-csrftoken="{{ csrf_token }}">
|
||||
<header class="app-header">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<a class="flex items-center gap-2.5" href="{% url "mobile:home" %}">
|
||||
{% if club.logo %}
|
||||
<img class="app-crest" src="{{ club.logo.url }}" alt="">
|
||||
{% else %}
|
||||
<span class="app-crest"></span>
|
||||
{% endif %}
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate font-display text-[19px] leading-none font-extrabold text-white uppercase">{{ club.name }}</span>
|
||||
{% if season %}<span class="block font-mono text-xs text-on-dark">{% blocktrans with name=season.name %}Season {{ name }}{% endblocktrans %}</span>{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
<div class="flex-1"></div>
|
||||
<a class="relative flex h-11 w-11 shrink-0 items-center justify-center" href="{% url "mobile:notifications" %}" aria-label="{% trans "Notifications" %}">
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
{% if unread_notification_count %}<span class="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-club" style="border: 2px solid var(--color-navy)"></span>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if managed_people|length > 1 %}
|
||||
<div class="mt-3 flex gap-2 overflow-x-auto pb-0.5">
|
||||
{% for person in managed_people %}
|
||||
<a class="person-chip {% if person == scope_person %}person-chip-active{% endif %}" href="?as={{ person.pk }}">
|
||||
<span class="person-chip-avatar">{{ person.first_name|slice:":1" }}</span>
|
||||
<span class="person-chip-label">{% if person == me %}{% trans "Me" %}{% else %}{{ person.first_name }}{% endif %}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
{% if messages %}
|
||||
<div class="flex flex-col gap-2">
|
||||
{% for message in messages %}
|
||||
<div class="m-card p-3 text-sm font-medium text-ink">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock content %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav class="tab-bar">
|
||||
<a class="tab-bar-item {% if active_tab == "home" %}tab-bar-item-active{% endif %}" href="{% url "mobile:home" %}">
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9.5 12 3l9 6.5V21a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1Z"/></svg>
|
||||
<span class="tab-bar-label">{% trans "Home" %}</span>
|
||||
</a>
|
||||
<a class="tab-bar-item {% if active_tab == "calendar" %}tab-bar-item-active{% endif %}" href="{% url "mobile:calendar" %}">
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
||||
<span class="tab-bar-label">{% trans "Calendar" %}</span>
|
||||
</a>
|
||||
<a class="tab-bar-item {% if active_tab == "news" %}tab-bar-item-active{% endif %}" href="{% url "mobile:home" %}#news">
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h9l5 5v9a2 2 0 0 1-2 2Z"/><path d="M9 13h6M9 17h6M9 9h1"/></svg>
|
||||
<span class="tab-bar-label">{% trans "News" %}</span>
|
||||
</a>
|
||||
<a class="tab-bar-item {% if active_tab == "me" %}tab-bar-item-active{% endif %}" href="{% url "mobile:me" %}">
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg>
|
||||
<span class="tab-bar-label">{% trans "Me" %}</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<script src="{% static "js/mobile-app.js" %}" defer></script>
|
||||
{% block extra_body %}{% endblock extra_body %}
|
||||
</body>
|
||||
</html>
|
||||
58
mobile/templates/mobile/sw.js
Normal file
58
mobile/templates/mobile/sw.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// RosterChief member app -- service worker. Served at /app/sw.js by
|
||||
// mobile.views.ServiceWorkerView (not /static/) so its default scope is /app/.
|
||||
//
|
||||
// v1 scope: push notifications + a minimal offline-friendly cache for the app
|
||||
// shell's own static assets. No page-content caching -- every screen here is
|
||||
// live club data (RSVP status, dues, news), and serving a stale copy while
|
||||
// offline would be actively misleading. The offline attendance queue
|
||||
// described in the design doc is Coach mode, a later phase.
|
||||
|
||||
const SHELL_CACHE = "rosterchief-shell-v1";
|
||||
const SHELL_ASSETS = ["/static/css/mobile.css", "/static/js/htmx.js", "/static/js/alpine.js", "/static/js/mobile-app.js"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== SHELL_CACHE).map((key) => caches.delete(key)))),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET" || !SHELL_ASSETS.some((asset) => event.request.url.endsWith(asset))) return;
|
||||
event.respondWith(caches.match(event.request).then((cached) => cached || fetch(event.request)));
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let payload = { title: "RosterChief", body: "" };
|
||||
if (event.data) {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload.body = event.data.text();
|
||||
}
|
||||
}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title, {
|
||||
body: payload.body,
|
||||
data: { url: payload.url || "/app/" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/app/";
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
if (client.url === url && "focus" in client) return client.focus();
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
}),
|
||||
);
|
||||
});
|
||||
149
mobile/tests.py
Normal file
149
mobile/tests.py
Normal file
@@ -0,0 +1,149 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from club.models import Club, ClubMembership, Season
|
||||
from members.models import Family, FamilyMembership, Member
|
||||
|
||||
from .models import PushSubscription
|
||||
from .services.icons import render_fallback_icon
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def make_club(**kwargs):
|
||||
return Club.objects.create(name="Ajax United", slug="ajax-united", secondary_color="#e4002b", **kwargs)
|
||||
|
||||
|
||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||
class MobileShellTests(TestCase):
|
||||
"""The app shell (base.html) plus the PWA plumbing every screen sits on."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = make_club()
|
||||
Season.objects.create(club=cls.club, start_date=datetime.date(2025, 8, 1), end_date=datetime.date(2026, 5, 31))
|
||||
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
|
||||
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user)
|
||||
ClubMembership.objects.create(club=cls.club, member=cls.member, season=Season.objects.first())
|
||||
|
||||
def _get(self, url_name, **kwargs):
|
||||
return self.client.get(reverse(f"mobile:{url_name}", kwargs=kwargs), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
def test_home_requires_login(self):
|
||||
response = self._get("home")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_home_renders_the_app_shell_when_signed_in(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get("home")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.club.name)
|
||||
self.assertContains(response, 'rel="manifest"')
|
||||
|
||||
def test_manifest_names_the_club_and_points_at_its_icon(self):
|
||||
response = self._get("manifest")
|
||||
|
||||
self.assertEqual(response["Content-Type"], "application/manifest+json")
|
||||
self.assertEqual(response.json()["name"], self.club.name)
|
||||
self.assertIn("icon", response.json()["icons"][0]["src"])
|
||||
|
||||
def test_icon_falls_back_to_a_rendered_png_without_a_logo(self):
|
||||
response = self._get("icon", size=192)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "image/png")
|
||||
|
||||
def test_service_worker_is_served_at_the_app_scope(self):
|
||||
response = self.client.get("/app/sw.js", HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("javascript", response["Content-Type"])
|
||||
self.assertIn("addEventListener", response.content.decode())
|
||||
|
||||
def test_person_switcher_lists_managed_children_alongside_me(self):
|
||||
family = Family.objects.create(name="Bakker")
|
||||
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
|
||||
child = Member.objects.create(first_name="Noor", last_name="Bakker")
|
||||
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
||||
ClubMembership.objects.create(club=self.club, member=child, season=Season.objects.first())
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get("home")
|
||||
|
||||
self.assertContains(response, "Noor")
|
||||
|
||||
def test_scope_person_switches_via_the_as_query_param(self):
|
||||
family = Family.objects.create(name="Bakker")
|
||||
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
|
||||
child = Member.objects.create(first_name="Noor", last_name="Bakker")
|
||||
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
||||
ClubMembership.objects.create(club=self.club, member=child, season=Season.objects.first())
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("mobile:home") + f"?as={child.pk}", HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
self.assertEqual(response.context["scope_person"], child)
|
||||
|
||||
|
||||
@override_settings(
|
||||
VAPID_PRIVATE_KEY="",
|
||||
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
||||
)
|
||||
class PushSubscribeViewTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = make_club()
|
||||
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
|
||||
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", user=cls.user)
|
||||
|
||||
def test_subscribing_creates_a_push_subscription_row(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("mobile:push_subscribe"),
|
||||
data={"endpoint": "https://push.example.com/abc", "keys": {"p256dh": "key1", "auth": "key2"}},
|
||||
content_type="application/json",
|
||||
HTTP_HOST="ajax-united.rosterchief.app",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(PushSubscription.objects.filter(member=self.member, endpoint="https://push.example.com/abc").exists())
|
||||
|
||||
def test_resubscribing_the_same_endpoint_updates_rather_than_duplicates(self):
|
||||
self.client.force_login(self.user)
|
||||
PushSubscription.objects.create(club=self.club, member=self.member, endpoint="https://push.example.com/abc", p256dh="old", auth="old")
|
||||
|
||||
self.client.post(
|
||||
reverse("mobile:push_subscribe"),
|
||||
data={"endpoint": "https://push.example.com/abc", "keys": {"p256dh": "new", "auth": "new"}},
|
||||
content_type="application/json",
|
||||
HTTP_HOST="ajax-united.rosterchief.app",
|
||||
)
|
||||
|
||||
self.assertEqual(PushSubscription.objects.filter(endpoint="https://push.example.com/abc").count(), 1)
|
||||
self.assertEqual(PushSubscription.objects.get(endpoint="https://push.example.com/abc").p256dh, "new")
|
||||
|
||||
def test_anonymous_cannot_subscribe(self):
|
||||
response = self.client.post(
|
||||
reverse("mobile:push_subscribe"),
|
||||
data={"endpoint": "https://push.example.com/abc", "keys": {"p256dh": "key1", "auth": "key2"}},
|
||||
content_type="application/json",
|
||||
HTTP_HOST="ajax-united.rosterchief.app",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class RenderFallbackIconTests(TestCase):
|
||||
def test_renders_a_png_without_a_logo(self):
|
||||
club = make_club()
|
||||
|
||||
png_bytes = render_fallback_icon(club, size=64)
|
||||
|
||||
self.assertTrue(png_bytes.startswith(b"\x89PNG"))
|
||||
21
mobile/urls.py
Normal file
21
mobile/urls.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "mobile"
|
||||
|
||||
urlpatterns = [
|
||||
# PWA plumbing.
|
||||
path("manifest.webmanifest", views.ManifestView.as_view(), name="manifest"),
|
||||
path("sw.js", views.ServiceWorkerView.as_view(), name="service_worker"),
|
||||
path("icon/<int:size>.png", views.AppIconView.as_view(), name="icon"),
|
||||
path("push/subscribe/", views.PushSubscribeView.as_view(), name="push_subscribe"),
|
||||
# Member mode (M1-M7).
|
||||
path("", views.HomeView.as_view(), name="home"),
|
||||
path("calendar/", views.CalendarView.as_view(), name="calendar"),
|
||||
path("events/<uuid:pk>/", views.EventDetailView.as_view(), name="event_detail"),
|
||||
path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"),
|
||||
path("me/", views.MeView.as_view(), name="me"),
|
||||
path("me/<uuid:member_id>/edit/", views.EditProfileView.as_view(), name="edit_profile"),
|
||||
path("notifications/", views.NotificationsView.as_view(), name="notifications"),
|
||||
]
|
||||
156
mobile/views.py
Normal file
156
mobile/views.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user