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
This commit is contained in:
@@ -175,6 +175,12 @@
|
|||||||
padding-top: env(safe-area-inset-top);
|
padding-top: env(safe-area-inset-top);
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Standard Alpine convention -- hides an x-cloak'd element until Alpine has
|
||||||
|
initialized, so an x-show/x-if starting state never flashes unstyled. */
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import PushSubscription
|
from .models import CalendarFeedToken, PushSubscription
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PushSubscription)
|
@admin.register(PushSubscription)
|
||||||
@@ -9,3 +9,11 @@ class PushSubscriptionAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ["club"]
|
list_filter = ["club"]
|
||||||
search_fields = ["member__first_name", "member__last_name", "endpoint"]
|
search_fields = ["member__first_name", "member__last_name", "endpoint"]
|
||||||
autocomplete_fields = ["member"]
|
autocomplete_fields = ["member"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CalendarFeedToken)
|
||||||
|
class CalendarFeedTokenAdmin(admin.ModelAdmin):
|
||||||
|
# Never the token itself -- it's a bearer credential, not something to
|
||||||
|
# surface in a list view even to platform staff.
|
||||||
|
list_display = ["user", "created", "modified"]
|
||||||
|
search_fields = ["user__email"]
|
||||||
|
|||||||
32
mobile/migrations/0002_calendarfeedtoken.py
Normal file
32
mobile/migrations/0002_calendarfeedtoken.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-21 13:09
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import mobile.models
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mobile', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CalendarFeedToken',
|
||||||
|
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)),
|
||||||
|
('token', models.CharField(default=mobile.models._generate_feed_token, editable=False, max_length=64, unique=True, verbose_name='token')),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='calendar_feed_token', to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'calendar feed token',
|
||||||
|
'verbose_name_plural': 'calendar feed tokens',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from rosterchief.base import ClubScopedModel, validate_club_scope
|
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_feed_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
class PushSubscription(ClubScopedModel):
|
class PushSubscription(ClubScopedModel):
|
||||||
@@ -36,3 +43,33 @@ class PushSubscription(ClubScopedModel):
|
|||||||
|
|
||||||
def as_subscription_info(self) -> dict:
|
def as_subscription_info(self) -> dict:
|
||||||
return {"endpoint": self.endpoint, "keys": {"p256dh": self.p256dh, "auth": self.auth}}
|
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"])
|
||||||
|
|||||||
78
mobile/services/calendar_feed.py
Normal file
78
mobile/services/calendar_feed.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Builds the .ics feed mobile.views.CalendarFeedView serves -- see that
|
||||||
|
view's own docstring for the shape (one combined feed per account, every
|
||||||
|
managed person's events, regardless of RSVP status).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from icalendar import Calendar
|
||||||
|
from icalendar import Event as ICalEvent
|
||||||
|
|
||||||
|
from events.models import Attendance
|
||||||
|
|
||||||
|
#: Non-game events don't always have an explicit `end` -- same assumed-duration
|
||||||
|
#: read-time fallback events.services.calendar._local_span()/events.models.
|
||||||
|
#: ASSUMED_EVENT_DURATION already use for display purposes.
|
||||||
|
_ASSUMED_DURATION = datetime.timedelta(hours=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(value):
|
||||||
|
return timezone.localtime(value).astimezone(datetime.UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def build_feed(club, people) -> bytes:
|
||||||
|
"""A combined VCALENDAR of every one of ``people``'s Attendance rows in
|
||||||
|
``club``, regardless of RSVP status -- this mirrors what the in-app
|
||||||
|
Calendar screen shows, minus its 2-week window: a synced calendar app is
|
||||||
|
exactly where someone wants the *whole* season visible, not a
|
||||||
|
deliberately-short mobile agenda. Still-in-progress events are kept
|
||||||
|
(filtered on effective end, not start) so one doesn't vanish from a
|
||||||
|
subscriber's calendar partway through. A cancelled event stays in the
|
||||||
|
feed with STATUS:CANCELLED rather than simply disappearing from it, so a
|
||||||
|
calendar app that already synced it removes it properly on next refresh.
|
||||||
|
"""
|
||||||
|
calendar = Calendar()
|
||||||
|
calendar.add("prodid", "-//RosterChief//Member calendar feed//EN")
|
||||||
|
calendar.add("version", "2.0")
|
||||||
|
calendar.add("calscale", "GREGORIAN")
|
||||||
|
calendar.add("method", "PUBLISH")
|
||||||
|
calendar.add("x-wr-calname", _("%(club)s — RosterChief") % {"club": club.name})
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
show_member_name = len(people) > 1
|
||||||
|
dtstamp = _utc(now)
|
||||||
|
|
||||||
|
attendances = (
|
||||||
|
Attendance.objects.filter(member__in=people, event__club=club, event__start__gte=now - datetime.timedelta(days=1))
|
||||||
|
.select_related("event", "event__location", "member")
|
||||||
|
.order_by("event__start")
|
||||||
|
)
|
||||||
|
|
||||||
|
for attendance in attendances:
|
||||||
|
event = attendance.event
|
||||||
|
end = event.end or (event.start + _ASSUMED_DURATION)
|
||||||
|
if end < now:
|
||||||
|
continue
|
||||||
|
|
||||||
|
component = ICalEvent()
|
||||||
|
component.add("uid", f"attendance-{attendance.pk}@rosterchief.app")
|
||||||
|
component.add("dtstamp", dtstamp)
|
||||||
|
component.add("dtstart", _utc(event.start))
|
||||||
|
component.add("dtend", _utc(end))
|
||||||
|
component.add("summary", _("%(title)s — %(name)s") % {"title": event.title, "name": attendance.member.first_name} if show_member_name else event.title)
|
||||||
|
component.add("status", "CANCELLED" if event.cancelled else "CONFIRMED")
|
||||||
|
|
||||||
|
if event.location:
|
||||||
|
address = ", ".join(part for part in [event.location.name, event.location.address, f"{event.location.zip_code} {event.location.city}".strip()] if part)
|
||||||
|
component.add("location", address)
|
||||||
|
|
||||||
|
description_lines = [_("RSVP: %(status)s") % {"status": attendance.get_status_display()}]
|
||||||
|
if event.gathering:
|
||||||
|
description_lines.append(_("Meet: %(time)s") % {"time": timezone.localtime(event.gathering).strftime("%H:%M")})
|
||||||
|
component.add("description", "\n".join(description_lines))
|
||||||
|
|
||||||
|
calendar.add_component(component)
|
||||||
|
|
||||||
|
return calendar.to_ical()
|
||||||
47
mobile/templates/mobile/calendar_feed_settings.html
Normal file
47
mobile/templates/mobile/calendar_feed_settings.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "mobile/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
M5's "Calendar sync" row -- subscribe an external calendar app (Apple/
|
||||||
|
Google/Outlook) to this account's combined feed. Not real-time: calendar
|
||||||
|
apps poll a subscribed feed on their own schedule, typically every few
|
||||||
|
hours -- the in-app push notifications (M7) are the timely channel, this
|
||||||
|
is a convenience layer on top, not a replacement.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="-mx-4 -mt-4 flex items-center gap-1 border-b border-line bg-white px-4 py-3">
|
||||||
|
<a class="-ml-2.5 flex h-11 w-11 shrink-0 items-center justify-center" href="{% url "mobile:me" %}" aria-label="{% trans "Back" %}">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#0b1220" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-display text-xl leading-none font-extrabold text-ink uppercase">{% trans "Calendar sync" %}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-muted">{% trans "Subscribe this link in your phone or computer's calendar app to see everyone you manage's schedule alongside your own. It includes every event they're invited to, not just the ones they've replied to." %}</p>
|
||||||
|
|
||||||
|
<div class="m-card p-4">
|
||||||
|
<a class="btn btn-primary flex w-full items-center justify-center" href="{{ webcal_url }}">{% trans "Add to calendar" %}</a>
|
||||||
|
<p class="mt-2 text-center text-xs text-dim">{% trans "Opens directly in Apple/macOS Calendar. On Android or a desktop calendar app, copy the link below and add it as a new calendar by URL." %}</p>
|
||||||
|
|
||||||
|
<div class="mt-4" x-data="{ copied: false }">
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-stroke bg-paper px-3 py-2.5">
|
||||||
|
<span class="min-w-0 flex-1 truncate font-mono text-xs text-muted">{{ feed_url }}</span>
|
||||||
|
<button type="button" class="shrink-0 font-display text-xs font-extrabold tracking-wide text-club uppercase" @click="navigator.clipboard.writeText('{{ feed_url|escapejs }}'); copied = true; setTimeout(() => copied = false, 2000)">
|
||||||
|
<span x-show="!copied">{% trans "Copy" %}</span>
|
||||||
|
<span x-show="copied" x-cloak>{% trans "Copied" %}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-dim">{% trans "Updates can take a few hours to appear in your calendar app -- it checks in on its own schedule, RosterChief can't push to it directly." %}</p>
|
||||||
|
|
||||||
|
<div class="m-card p-4">
|
||||||
|
<div class="text-sm font-semibold text-ink">{% trans "Reset link" %}</div>
|
||||||
|
<p class="mt-1 text-xs text-muted">{% trans "If you've shared this link and want to take it back, reset it -- the old one stops working immediately and you'll need to re-subscribe with the new one." %}</p>
|
||||||
|
<form class="mt-3" method="post" action="{% url "mobile:calendar_feed_settings" %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-secondary w-full">{% trans "Reset my calendar link" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -67,6 +67,11 @@
|
|||||||
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Notifications" %}</span>
|
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Notifications" %}</span>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
||||||
</a>
|
</a>
|
||||||
|
<div class="h-px bg-rule"></div>
|
||||||
|
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:calendar_feed_settings" %}">
|
||||||
|
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Calendar sync" %}</span>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="pb-2 text-center text-xs text-dim">{% blocktrans with club_name=club.name %}RosterChief · {{ club_name }} · v1.0{% endblocktrans %}</p>
|
<p class="pb-2 text-center text-xs text-dim">{% blocktrans with club_name=club.name %}RosterChief · {{ club_name }} · v1.0{% endblocktrans %}</p>
|
||||||
|
|||||||
148
mobile/tests.py
148
mobile/tests.py
@@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone, translation
|
from django.utils import timezone, translation
|
||||||
|
from icalendar import Calendar as ICalCalendar
|
||||||
|
|
||||||
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
@@ -13,7 +14,7 @@ from news.models import News
|
|||||||
from notifications.models import Notification
|
from notifications.models import Notification
|
||||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||||
|
|
||||||
from .models import PushSubscription
|
from .models import CalendarFeedToken, PushSubscription
|
||||||
from .services.icons import render_fallback_icon
|
from .services.icons import render_fallback_icon
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -1262,3 +1263,148 @@ class EditProfileViewTests(TestCase):
|
|||||||
response = self._get(self.child)
|
response = self._get(self.child)
|
||||||
|
|
||||||
self.assertNotContains(response, "still open")
|
self.assertNotContains(response, "still open")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "other-club.rosterchief.app", "testserver"])
|
||||||
|
class CalendarFeedViewTests(TestCase):
|
||||||
|
"""The .ics subscription feed -- URL-token authenticated, combined across
|
||||||
|
every managed person, regardless of RSVP status."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = make_club()
|
||||||
|
cls.season = Season.objects.create(club=cls.club, start_date=timezone.localdate() - datetime.timedelta(days=30), end_date=timezone.localdate() + datetime.timedelta(days=300))
|
||||||
|
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)
|
||||||
|
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
|
||||||
|
cls.token = CalendarFeedToken.objects.create(user=cls.user)
|
||||||
|
|
||||||
|
def _get(self, token=None, host="ajax-united.rosterchief.app"):
|
||||||
|
return self.client.get(f"/app/calendar/{token or self.token.token}.ics", HTTP_HOST=host)
|
||||||
|
|
||||||
|
def test_no_login_required(self):
|
||||||
|
event = Event.objects.create(club=self.club, title="Practice", start=timezone.now() + datetime.timedelta(days=1))
|
||||||
|
Attendance.objects.create(event=event, member=self.member)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "text/calendar; charset=utf-8")
|
||||||
|
|
||||||
|
def test_404_for_an_unknown_token(self):
|
||||||
|
response = self._get(token="not-a-real-token")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_includes_events_regardless_of_rsvp_status(self):
|
||||||
|
in_event = Event.objects.create(club=self.club, title="Confirmed", start=timezone.now() + datetime.timedelta(days=1))
|
||||||
|
out_event = Event.objects.create(club=self.club, title="Declined", start=timezone.now() + datetime.timedelta(days=2))
|
||||||
|
no_reply_event = Event.objects.create(club=self.club, title="No reply yet", start=timezone.now() + datetime.timedelta(days=3))
|
||||||
|
Attendance.objects.create(event=in_event, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
||||||
|
Attendance.objects.create(event=out_event, member=self.member, status=Attendance.AttendanceStatus.ABSENT)
|
||||||
|
Attendance.objects.create(event=no_reply_event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
calendar = ICalCalendar.from_ical(response.content)
|
||||||
|
summaries = {str(component.get("summary")) for component in calendar.walk("VEVENT")}
|
||||||
|
self.assertEqual(summaries, {"Confirmed", "Declined", "No reply yet"})
|
||||||
|
|
||||||
|
def test_includes_events_for_managed_children_with_their_name_in_the_summary(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=self.season)
|
||||||
|
event = Event.objects.create(club=self.club, title="Practice", start=timezone.now() + datetime.timedelta(days=1))
|
||||||
|
Attendance.objects.create(event=event, member=child)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
calendar = ICalCalendar.from_ical(response.content)
|
||||||
|
summaries = {str(component.get("summary")) for component in calendar.walk("VEVENT")}
|
||||||
|
self.assertIn("Practice — Noor", summaries)
|
||||||
|
|
||||||
|
def test_cancelled_events_stay_in_the_feed_marked_cancelled(self):
|
||||||
|
event = Event.objects.create(club=self.club, title="Rained out", start=timezone.now() + datetime.timedelta(days=1), cancelled=True)
|
||||||
|
Attendance.objects.create(event=event, member=self.member)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
calendar = ICalCalendar.from_ical(response.content)
|
||||||
|
component = next(iter(calendar.walk("VEVENT")))
|
||||||
|
self.assertEqual(str(component.get("status")), "CANCELLED")
|
||||||
|
|
||||||
|
def test_excludes_events_that_already_ended(self):
|
||||||
|
Event.objects.create(club=self.club, title="Yesterday", start=timezone.now() - datetime.timedelta(days=2))
|
||||||
|
Attendance.objects.create(event=Event.objects.get(title="Yesterday"), member=self.member)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
calendar = ICalCalendar.from_ical(response.content)
|
||||||
|
self.assertEqual(list(calendar.walk("VEVENT")), [])
|
||||||
|
|
||||||
|
def test_keeps_a_still_in_progress_event(self):
|
||||||
|
event = Event.objects.create(club=self.club, title="Right now", start=timezone.now() - datetime.timedelta(minutes=30), end=timezone.now() + datetime.timedelta(minutes=30))
|
||||||
|
Attendance.objects.create(event=event, member=self.member)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
calendar = ICalCalendar.from_ical(response.content)
|
||||||
|
summaries = {str(component.get("summary")) for component in calendar.walk("VEVENT")}
|
||||||
|
self.assertIn("Right now", summaries)
|
||||||
|
|
||||||
|
def test_scoped_to_the_club_the_url_is_fetched_on(self):
|
||||||
|
# The same token/account can be a member of more than one club -- the
|
||||||
|
# feed must only ever show whichever club's subdomain it was fetched
|
||||||
|
# on, the same tenant scoping every other page in this app relies on.
|
||||||
|
other_club = Club.objects.create(name="Other Club", slug="other-club", secondary_color="#e4002b")
|
||||||
|
other_season = Season.objects.create(club=other_club, start_date=timezone.localdate() - datetime.timedelta(days=30), end_date=timezone.localdate() + datetime.timedelta(days=300))
|
||||||
|
ClubMembership.objects.create(club=other_club, member=self.member, season=other_season)
|
||||||
|
this_event = Event.objects.create(club=self.club, title="This club", start=timezone.now() + datetime.timedelta(days=1))
|
||||||
|
other_event = Event.objects.create(club=other_club, title="Not this club", start=timezone.now() + datetime.timedelta(days=1))
|
||||||
|
Attendance.objects.create(event=this_event, member=self.member)
|
||||||
|
Attendance.objects.create(event=other_event, member=self.member)
|
||||||
|
|
||||||
|
response = self._get(host="other-club.rosterchief.app")
|
||||||
|
|
||||||
|
calendar = ICalCalendar.from_ical(response.content)
|
||||||
|
summaries = {str(component.get("summary")) for component in calendar.walk("VEVENT")}
|
||||||
|
self.assertEqual(summaries, {"Not this club"})
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
|
class CalendarFeedSettingsViewTests(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 _get(self):
|
||||||
|
return self.client.get(reverse("mobile:calendar_feed_settings"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_creates_a_token_on_first_visit_and_shows_its_url(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
token = CalendarFeedToken.objects.get(user=self.user)
|
||||||
|
self.assertContains(response, f"{token.token}.ics")
|
||||||
|
self.assertContains(response, "webcal://")
|
||||||
|
|
||||||
|
def test_reset_issues_a_new_token_invalidating_the_old_one(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
old_token = CalendarFeedToken.objects.create(user=self.user)
|
||||||
|
old_value = old_token.token
|
||||||
|
|
||||||
|
response = self.client.post(reverse("mobile:calendar_feed_settings"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("mobile:calendar_feed_settings"), fetch_redirect_response=False)
|
||||||
|
old_token.refresh_from_db()
|
||||||
|
self.assertNotEqual(old_token.token, old_value)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ urlpatterns = [
|
|||||||
path("sw.js", views.ServiceWorkerView.as_view(), name="service_worker"),
|
path("sw.js", views.ServiceWorkerView.as_view(), name="service_worker"),
|
||||||
path("icon/<int:size>.png", views.AppIconView.as_view(), name="icon"),
|
path("icon/<int:size>.png", views.AppIconView.as_view(), name="icon"),
|
||||||
path("push/subscribe/", views.PushSubscribeView.as_view(), name="push_subscribe"),
|
path("push/subscribe/", views.PushSubscribeView.as_view(), name="push_subscribe"),
|
||||||
|
path("calendar/<str:token>.ics", views.CalendarFeedView.as_view(), name="calendar_feed"),
|
||||||
# Member mode (M1-M7).
|
# Member mode (M1-M7).
|
||||||
path("", views.HomeView.as_view(), name="home"),
|
path("", views.HomeView.as_view(), name="home"),
|
||||||
path("calendar/", views.CalendarView.as_view(), name="calendar"),
|
path("calendar/", views.CalendarView.as_view(), name="calendar"),
|
||||||
@@ -17,6 +18,7 @@ urlpatterns = [
|
|||||||
path("news/", views.NewsListView.as_view(), name="news_list"),
|
path("news/", views.NewsListView.as_view(), name="news_list"),
|
||||||
path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"),
|
path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"),
|
||||||
path("me/", views.MeView.as_view(), name="me"),
|
path("me/", views.MeView.as_view(), name="me"),
|
||||||
|
path("me/calendar-sync/", views.CalendarFeedSettingsView.as_view(), name="calendar_feed_settings"),
|
||||||
path("me/<uuid:member_id>/edit/", views.EditProfileView.as_view(), name="edit_profile"),
|
path("me/<uuid:member_id>/edit/", views.EditProfileView.as_view(), name="edit_profile"),
|
||||||
path("notifications/", views.NotificationsView.as_view(), name="notifications"),
|
path("notifications/", views.NotificationsView.as_view(), name="notifications"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ from teams.models import TeamMembership
|
|||||||
|
|
||||||
from .forms import MemberProfileForm
|
from .forms import MemberProfileForm
|
||||||
from .mixins import PersonScopeMixin
|
from .mixins import PersonScopeMixin
|
||||||
from .models import PushSubscription
|
from .models import CalendarFeedToken, PushSubscription
|
||||||
|
from .services.calendar_feed import build_feed
|
||||||
from .services.icons import render_fallback_icon
|
from .services.icons import render_fallback_icon
|
||||||
|
|
||||||
|
|
||||||
@@ -121,6 +122,54 @@ class PushSubscribeView(LoginRequiredMixin, ClubScopedPublicMixin, View):
|
|||||||
return JsonResponse({"status": "ok"})
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarFeedView(ClubScopedPublicMixin, View):
|
||||||
|
"""The .ics subscription feed a calendar app polls -- URL-token
|
||||||
|
authenticated, not LoginRequiredMixin (a calendar app can't do
|
||||||
|
interactive login). See mobile.services.calendar_feed.build_feed for the
|
||||||
|
feed's own shape/scope, and CalendarFeedToken's own docstring for why one
|
||||||
|
token covers every club the account belongs to."""
|
||||||
|
|
||||||
|
def get(self, request, token):
|
||||||
|
feed_token = get_object_or_404(CalendarFeedToken.objects.select_related("user"), token=token)
|
||||||
|
me = Member.objects.filter(user=feed_token.user).first()
|
||||||
|
people = []
|
||||||
|
if me is not None:
|
||||||
|
children = Member.objects.filter(
|
||||||
|
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
|
||||||
|
family_memberships__family__memberships__member=me,
|
||||||
|
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
|
||||||
|
member_of__club=request.club,
|
||||||
|
).distinct()
|
||||||
|
people = [me, *children]
|
||||||
|
|
||||||
|
feed = build_feed(request.club, people)
|
||||||
|
return HttpResponse(feed, content_type="text/calendar; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarFeedSettingsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""M5's "Calendar sync" row -- the webcal://.../https:// subscription
|
||||||
|
links for this account's combined feed, plus a reset action that
|
||||||
|
immediately invalidates the old URL (e.g. a link shared/leaked by
|
||||||
|
accident)."""
|
||||||
|
|
||||||
|
template_name = "mobile/calendar_feed_settings.html"
|
||||||
|
screen_title = _("Calendar sync")
|
||||||
|
active_tab = "me"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
feed_token, _created = CalendarFeedToken.objects.get_or_create(user=self.request.user)
|
||||||
|
feed_url = self.request.build_absolute_uri(reverse("mobile:calendar_feed", kwargs={"token": feed_token.token}))
|
||||||
|
return super().get_context_data(feed_url=feed_url, webcal_url=feed_url.replace("https://", "webcal://", 1).replace("http://", "webcal://", 1), **kwargs)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
feed_token, _created = CalendarFeedToken.objects.get_or_create(user=request.user)
|
||||||
|
feed_token.regenerate()
|
||||||
|
title = _("Calendar link reset")
|
||||||
|
body = _("Your old calendar link no longer works. Re-subscribe using the new one below.")
|
||||||
|
notify(request, f"s|{title}|{body}")
|
||||||
|
return HttpResponseRedirect(reverse("mobile:calendar_feed_settings"))
|
||||||
|
|
||||||
|
|
||||||
class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
|
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
|
||||||
hero card for the soonest upcoming event across everyone currently in
|
hero card for the soonest upcoming event across everyone currently in
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ dependencies = [
|
|||||||
"django-storages[s3]>=1.14.6",
|
"django-storages[s3]>=1.14.6",
|
||||||
"django-waffle>=5.0.0",
|
"django-waffle>=5.0.0",
|
||||||
"gunicorn>=26.0.0",
|
"gunicorn>=26.0.0",
|
||||||
|
"icalendar>=6.1.0",
|
||||||
"markdown>=3.10.3",
|
"markdown>=3.10.3",
|
||||||
"nh3>=0.3.6",
|
"nh3>=0.3.6",
|
||||||
"openpyxl>=3.1.5",
|
"openpyxl>=3.1.5",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
15
uv.lock
generated
15
uv.lock
generated
@@ -760,6 +760,19 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/af/249d1576653b69c20b9ac30e284b63bd94af6a175d72d87813235caf2482/http_ece-1.2.1.tar.gz", hash = "sha256:8c6ab23116bbf6affda894acfd5f2ca0fb8facbcbb72121c11c75c33e7ce8cff", size = 8830, upload-time = "2024-08-08T00:10:47.301Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7c/af/249d1576653b69c20b9ac30e284b63bd94af6a175d72d87813235caf2482/http_ece-1.2.1.tar.gz", hash = "sha256:8c6ab23116bbf6affda894acfd5f2ca0fb8facbcbb72121c11c75c33e7ce8cff", size = 8830, upload-time = "2024-08-08T00:10:47.301Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icalendar"
|
||||||
|
version = "7.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "tzdata" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/2b/1bbf82d316df18c3331d9a06228819c8a5814ceda545a3e9980e52ffce1b/icalendar-7.3.0.tar.gz", hash = "sha256:7bd001c8e648205e1bde5c6a5b77096598e8d0893dcf57755c6c597635620132", size = 525811, upload-time = "2026-08-19T15:10:21.05Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/82/50bff78b0bb0c7d7c0cb39e0ee189b92f611fff6bd7cf57f25e92d5a7551/icalendar-7.3.0-py3-none-any.whl", hash = "sha256:8355acfe17be81b368f0b1e3740817cea9b56ea889931f8f1a87c62f2d28db0b", size = 542540, upload-time = "2026-08-19T15:10:19.525Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.18"
|
version = "3.18"
|
||||||
@@ -1242,6 +1255,7 @@ dependencies = [
|
|||||||
{ name = "django-storages", extra = ["s3"] },
|
{ name = "django-storages", extra = ["s3"] },
|
||||||
{ name = "django-waffle" },
|
{ name = "django-waffle" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
|
{ name = "icalendar" },
|
||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
{ name = "nh3" },
|
{ name = "nh3" },
|
||||||
{ name = "openpyxl" },
|
{ name = "openpyxl" },
|
||||||
@@ -1277,6 +1291,7 @@ requires-dist = [
|
|||||||
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
||||||
{ name = "django-waffle", specifier = ">=5.0.0" },
|
{ name = "django-waffle", specifier = ">=5.0.0" },
|
||||||
{ name = "gunicorn", specifier = ">=26.0.0" },
|
{ name = "gunicorn", specifier = ">=26.0.0" },
|
||||||
|
{ name = "icalendar", specifier = ">=6.1.0" },
|
||||||
{ name = "markdown", specifier = ">=3.10.3" },
|
{ name = "markdown", specifier = ">=3.10.3" },
|
||||||
{ name = "nh3", specifier = ">=0.3.6" },
|
{ name = "nh3", specifier = ">=0.3.6" },
|
||||||
{ name = "openpyxl", specifier = ">=3.1.5" },
|
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||||
|
|||||||
Reference in New Issue
Block a user