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:
2026-08-21 10:27:13 +02:00
parent 0ecdeac354
commit 09df5d25b8
29 changed files with 1657 additions and 6 deletions

0
mobile/__init__.py Normal file
View File

11
mobile/admin.py Normal file
View 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
View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class MobileConfig(AppConfig):
name = "mobile"
def ready(self):
from . import signals # noqa: F401

View 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'],
},
),
]

View File

69
mobile/mixins.py Normal file
View 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
View 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}}

View File

36
mobile/services/icons.py Normal file
View 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
View 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
View 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)

View 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 %}

View 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 }} &middot; {{ 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>

View 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
View 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
View 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
View 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"