Files
RosterChief/mobile/models.py
Bernard Siebens 515480ce51 Add calendar sync: a combined iCal subscription feed per account
New mobile:calendar_feed (/app/calendar/<token>.ics) -- a standard
webcal-style subscription feed calendar apps can poll, so a family's
schedule shows up in Apple/Google/Outlook Calendar alongside everything
else. Token-authenticated rather than session-authenticated (calendar
apps can't do interactive login); CalendarFeedToken is deliberately not
club-scoped, since the same token works under whichever club subdomain
the request is fetched from -- an account active in more than one club
needs only the one link.

Combined across every managed person (their name goes in the event
title when there's more than one), every event they're invited to
regardless of RSVP status, not capped to Calendar's own 2-week window --
a synced calendar app is exactly where someone wants the whole season
visible. A cancelled event stays in the feed as STATUS:CANCELLED rather
than disappearing, so it's removed properly on the subscriber's next
refresh instead of just vanishing.

New mobile:calendar_feed_settings (linked from Me) shows the webcal://
and https:// links plus a "reset my calendar link" action that
immediately invalidates the old one. Uses the icalendar package for
RFC 5545 generation rather than hand-rolling escaping/line-folding.

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

76 lines
3.2 KiB
Python

import secrets
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
def _generate_feed_token() -> str:
return secrets.token_urlsafe(32)
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}}
class CalendarFeedToken(UUIDModel):
"""The secret key one account's combined iCal subscription feed
(mobile.views.CalendarFeedView) is served under -- one per account, every
managed person's events combined into it (see that view's own docstring).
Not club-scoped, unlike PushSubscription: ``User`` is the one global
model in this platform (CLAUDE.md), and the same token works under
whichever club subdomain the feed URL is fetched from -- the view
resolves ``request.club`` and this account's Member/managed people
within it exactly like every other screen already does, so an account
active in more than one club still needs only the one token.
"""
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="calendar_feed_token", verbose_name=_("user"))
token = models.CharField(_("token"), max_length=64, unique=True, editable=False, default=_generate_feed_token)
class Meta:
verbose_name = _("calendar feed token")
verbose_name_plural = _("calendar feed tokens")
def __str__(self):
return f"Calendar feed — {self.user}"
def regenerate(self) -> None:
"""Invalidates the old URL immediately -- e.g. a shared/leaked link
someone wants to revoke (mobile.views.CalendarFeedSettingsView)."""
self.token = _generate_feed_token()
self.save(update_fields=["token", "modified"])