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:
2026-08-21 15:15:11 +02:00
parent 0e81e729b3
commit 515480ce51
13 changed files with 431 additions and 5 deletions

View File

@@ -175,6 +175,12 @@
padding-top: env(safe-area-inset-top);
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 {

View File

@@ -1,6 +1,6 @@
from django.contrib import admin
from .models import PushSubscription
from .models import CalendarFeedToken, PushSubscription
@admin.register(PushSubscription)
@@ -9,3 +9,11 @@ class PushSubscriptionAdmin(admin.ModelAdmin):
list_filter = ["club"]
search_fields = ["member__first_name", "member__last_name", "endpoint"]
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"]

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

View File

@@ -1,8 +1,15 @@
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, 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):
@@ -36,3 +43,33 @@ class PushSubscription(ClubScopedModel):
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"])

View 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()

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

View File

@@ -67,6 +67,11 @@
<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>
</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>
<p class="pb-2 text-center text-xs text-dim">{% blocktrans with club_name=club.name %}RosterChief &middot; {{ club_name }} &middot; v1.0{% endblocktrans %}</p>

View File

@@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone, translation
from icalendar import Calendar as ICalCalendar
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from events.models import Attendance, Event
@@ -13,7 +14,7 @@ from news.models import News
from notifications.models import Notification
from teams.models import Position, StaffAssignment, Team, TeamMembership
from .models import PushSubscription
from .models import CalendarFeedToken, PushSubscription
from .services.icons import render_fallback_icon
User = get_user_model()
@@ -1262,3 +1263,148 @@ class EditProfileViewTests(TestCase):
response = self._get(self.child)
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)

View File

@@ -10,6 +10,7 @@ urlpatterns = [
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"),
path("calendar/<str:token>.ics", views.CalendarFeedView.as_view(), name="calendar_feed"),
# Member mode (M1-M7).
path("", views.HomeView.as_view(), name="home"),
path("calendar/", views.CalendarView.as_view(), name="calendar"),
@@ -17,6 +18,7 @@ urlpatterns = [
path("news/", views.NewsListView.as_view(), name="news_list"),
path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"),
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("notifications/", views.NotificationsView.as_view(), name="notifications"),
]

View File

@@ -36,7 +36,8 @@ from teams.models import TeamMembership
from .forms import MemberProfileForm
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
@@ -121,6 +122,54 @@ class PushSubscribeView(LoginRequiredMixin, ClubScopedPublicMixin, View):
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):
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
hero card for the soonest upcoming event across everyone currently in

View File

@@ -16,6 +16,7 @@ dependencies = [
"django-storages[s3]>=1.14.6",
"django-waffle>=5.0.0",
"gunicorn>=26.0.0",
"icalendar>=6.1.0",
"markdown>=3.10.3",
"nh3>=0.3.6",
"openpyxl>=3.1.5",

File diff suppressed because one or more lines are too long

15
uv.lock generated
View File

@@ -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" }
[[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]]
name = "idna"
version = "3.18"
@@ -1242,6 +1255,7 @@ dependencies = [
{ name = "django-storages", extra = ["s3"] },
{ name = "django-waffle" },
{ name = "gunicorn" },
{ name = "icalendar" },
{ name = "markdown" },
{ name = "nh3" },
{ name = "openpyxl" },
@@ -1277,6 +1291,7 @@ requires-dist = [
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
{ name = "django-waffle", specifier = ">=5.0.0" },
{ name = "gunicorn", specifier = ">=26.0.0" },
{ name = "icalendar", specifier = ">=6.1.0" },
{ name = "markdown", specifier = ">=3.10.3" },
{ name = "nh3", specifier = ">=0.3.6" },
{ name = "openpyxl", specifier = ">=3.1.5" },