Skip to content

Commit 3691cff

Browse files
schlizclaude
andcommitted
feat: add token-based iCal subscription for user participations
Replace session-authenticated calendar feeds (unusable by calendar clients) with a single token-based endpoint per user. Each user can generate a persistent secret link that serves their shift participations as an iCal feed without requiring a browser session. - Add CalendarToken model with cryptographic token generation - Add TokenAuthMixin for unauthenticated feed access via URL token - Add calendar subscription UI card on user profile page - Remove old session-based feed routes from accounts, events, orgs - Remove ical export buttons from event/organization templates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 11c13e0 commit 3691cff

15 files changed

Lines changed: 231 additions & 22 deletions

File tree

src/shiftings/accounts/admin.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
from django.contrib import admin
22

3-
from shiftings.accounts.models import OIDCOfflineToken, User
3+
from shiftings.accounts.models import CalendarToken, OIDCOfflineToken, User
44

55
admin.site.register(User)
66

77

8+
@admin.register(CalendarToken)
9+
class CalendarTokenAdmin(admin.ModelAdmin):
10+
list_display = ('user', 'created')
11+
search_fields = ('user__username',)
12+
readonly_fields = ('token', 'created')
13+
14+
815
@admin.register(OIDCOfflineToken)
916
class OIDCOfflineTokenAdmin(admin.ModelAdmin):
1017
list_display = ('user', 'updated')
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 6.0.2 on 2026-03-15 22:51
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('accounts', '0002_oidcofflinetoken'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='CalendarToken',
17+
fields=[
18+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('token', models.CharField(db_index=True, max_length=64, unique=True, verbose_name='Token')),
20+
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
21+
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='calendar_token', to=settings.AUTH_USER_MODEL, verbose_name='User')),
22+
],
23+
options={
24+
'default_permissions': (),
25+
},
26+
),
27+
]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
from .calendar_token import CalendarToken
12
from .oidc_token import OIDCOfflineToken
23
from .user import BaseUser, User
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
import secrets
4+
from typing import Any
5+
6+
from django.conf import settings
7+
from django.db import models
8+
from django.utils.translation import gettext_lazy as _
9+
10+
11+
class CalendarToken(models.Model):
12+
user = models.OneToOneField(
13+
settings.AUTH_USER_MODEL,
14+
on_delete=models.CASCADE,
15+
related_name='calendar_token',
16+
verbose_name=_('User'),
17+
)
18+
token = models.CharField(
19+
max_length=64,
20+
unique=True,
21+
db_index=True,
22+
verbose_name=_('Token'),
23+
)
24+
created = models.DateTimeField(auto_now_add=True, verbose_name=_('Created'))
25+
26+
class Meta:
27+
default_permissions = ()
28+
29+
def __str__(self) -> str:
30+
return f'{self.user} ({self.token[:8]}...)'
31+
32+
def save(self, *args: Any, **kwargs: Any) -> None:
33+
if not self.token:
34+
self.token = secrets.token_hex(32)
35+
super().save(*args, **kwargs)
36+
37+
def regenerate(self) -> None:
38+
self.token = secrets.token_hex(32)
39+
self.save(update_fields=['token'])

src/shiftings/accounts/templates/accounts/user_detail.html

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,56 @@ <h4>{% trans "Organizations" %}</h4>
118118
{% endblock %}
119119

120120
{% block right %}
121+
<div class="card bg-dark mt-2 mb-3">
122+
<div class="card-header p-2 center-items justify-content-between">
123+
<h5 class="m-0 ms-2">{% trans "Calendar Subscription" %}</h5>
124+
<div class="center-items gap-1">
125+
{% if calendar_token %}
126+
<form action="{% url 'calendar_token_create' %}" method="post" class="d-inline">
127+
{% csrf_token %}
128+
<button type="submit" class="btn btn-sm btn-warning"
129+
onclick="return confirm('{% trans "Regenerating will invalidate the current link. Continue?" %}')"
130+
title="{% trans 'Regenerate' %}">
131+
<i class="fa-solid fa-rotate"></i>
132+
</button>
133+
</form>
134+
<form action="{% url 'calendar_token_delete' %}" method="post" class="d-inline">
135+
{% csrf_token %}
136+
<button type="submit" class="btn btn-sm btn-danger"
137+
onclick="return confirm('{% trans "Revoking will stop calendar sync. Continue?" %}')"
138+
title="{% trans 'Revoke' %}">
139+
<i class="fa-solid fa-trash"></i>
140+
</button>
141+
</form>
142+
{% else %}
143+
<form action="{% url 'calendar_token_create' %}" method="post" class="d-inline">
144+
{% csrf_token %}
145+
<button type="submit" class="btn btn-sm btn-success"
146+
title="{% trans 'Generate calendar link' %}">
147+
<i class="fa-solid fa-calendar-plus"></i>
148+
</button>
149+
</form>
150+
{% endif %}
151+
</div>
152+
</div>
153+
{% if calendar_token %}
154+
<div class="card-body p-3">
155+
<div class="input-group">
156+
<a class="btn btn-outline-success" href="{{ calendar_webcal_url }}"
157+
title="{% trans 'Add to Calendar' %}">
158+
<i class="fa-solid fa-calendar-plus"></i>
159+
</a>
160+
<input type="text" class="form-control form-control-sm"
161+
value="{{ calendar_url }}" readonly id="calendarUrl">
162+
<button class="btn btn-outline-secondary" type="button"
163+
onclick="navigator.clipboard.writeText(document.getElementById('calendarUrl').value)"
164+
title="{% trans 'Copy' %}">
165+
<i class="fa-solid fa-copy"></i>
166+
</button>
167+
</div>
168+
</div>
169+
{% endif %}
170+
</div>
121171
<div class="card bg-dark mt-2">
122172
<div class="card-header center-items justify-content-between">
123173
{% url "user_profile_past" as past_url %}

src/shiftings/accounts/urls/user.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
from shiftings.accounts.views.auth import UserLoginView, UserLogoutView, UserReLoginView
88
from shiftings.accounts.views.password import PasswordResetConfirmView, PasswordResetView
9+
from shiftings.accounts.views.calendar_token import CalendarTokenCreateView, CalendarTokenDeleteView
910
from shiftings.accounts.views.user import (ConfirmEMailView, UserDeleteSelfView, UserEditView, UserProfileView,
1011
UserRegisterView)
11-
from shiftings.cal.feed.user import OwnShiftsFeed, UserFeed
1212
from shiftings.utils.converters import AlphaNumericConverter
1313

1414
register_converter(AlphaNumericConverter, 'uidb64')
@@ -28,8 +28,8 @@
2828
path('password_reset/<uidb64>/<token>/', PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
2929
path('password_reset/success/', TemplateView.as_view(template_name='accounts/password_reset/success.html'),
3030
name='password_reset_success'),
31-
path('calendar/', UserFeed(), name='user_calendar'),
32-
path('participation_calendar/', OwnShiftsFeed(), name='user_participation_calendar'),
31+
path('calendar_token/create/', CalendarTokenCreateView.as_view(), name='calendar_token_create'),
32+
path('calendar_token/delete/', CalendarTokenDeleteView.as_view(), name='calendar_token_delete'),
3333
]
3434
if settings.FEATURES.get('registration', False):
3535
urlpatterns.append(path('register/', UserRegisterView.as_view(), name='register'))
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from __future__ import annotations
2+
3+
from django.contrib import messages
4+
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
5+
from django.urls import reverse
6+
from django.utils.translation import gettext_lazy as _
7+
from django.views import View
8+
9+
from shiftings.accounts.models import CalendarToken
10+
from shiftings.utils.views.base import BaseLoginMixin
11+
12+
13+
class CalendarTokenCreateView(BaseLoginMixin, View):
14+
def post(self, request: HttpRequest) -> HttpResponse:
15+
token, created = CalendarToken.objects.get_or_create(user=request.user)
16+
if not created:
17+
token.regenerate()
18+
messages.success(request, _('Calendar link regenerated. Old link will stop working.'))
19+
else:
20+
messages.success(request, _('Calendar link generated.'))
21+
return HttpResponseRedirect(reverse('user_profile'))
22+
23+
24+
class CalendarTokenDeleteView(BaseLoginMixin, View):
25+
def post(self, request: HttpRequest) -> HttpResponse:
26+
CalendarToken.objects.filter(user=request.user).delete()
27+
messages.success(request, _('Calendar link revoked.'))
28+
return HttpResponseRedirect(reverse('user_profile'))

src/shiftings/accounts/views/user.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from django.views.generic import CreateView, DetailView, TemplateView, UpdateView
2121

2222
from shiftings.accounts.forms.user_form import UserCreateForm, UserUpdateForm
23-
from shiftings.accounts.models import User
23+
from shiftings.accounts.models import CalendarToken, User
2424
from shiftings.accounts.token import email_confirm_token_generator
2525
from shiftings.shifts.models import Shift
2626
from shiftings.shifts.utils.filter_mixin import ShiftFilterMixin
@@ -49,6 +49,18 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
4949
Q(participants__user=self.object))).distinct()
5050
context['shifts'] = get_pagination_context(self.request, shifts.filter(self.get_filters()), 5, 'shifts')
5151

52+
try:
53+
calendar_token = CalendarToken.objects.get(user=self.object)
54+
except CalendarToken.DoesNotExist:
55+
calendar_token = None
56+
context['calendar_token'] = calendar_token
57+
if calendar_token:
58+
calendar_url = self.request.build_absolute_uri(
59+
reverse('token_participation_calendar', args=[calendar_token.token])
60+
)
61+
context['calendar_url'] = calendar_url
62+
context['calendar_webcal_url'] = calendar_url.replace('http://', 'webcal://', 1).replace('https://', 'webcal://', 1)
63+
5264
return context
5365

5466

src/shiftings/cal/feed/token.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from datetime import timedelta
5+
from typing import Any
6+
7+
from django.conf import settings
8+
from django.http import HttpRequest, HttpResponse
9+
from django.utils import timezone
10+
11+
from shiftings.accounts.models import CalendarToken
12+
from shiftings.cal.feed.user import OwnShiftsFeed
13+
from shiftings.utils.exceptions import Http403
14+
15+
logger = logging.getLogger(__name__)
16+
17+
OIDC_REFRESH_COOLDOWN = timedelta(minutes=10)
18+
19+
20+
class TokenAuthMixin:
21+
"""Authenticate iCal feed requests via a CalendarToken URL parameter."""
22+
23+
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
24+
token_str = kwargs.pop('token', None)
25+
if not token_str or len(token_str) != 64:
26+
raise Http403()
27+
28+
try:
29+
calendar_token = CalendarToken.objects.select_related('user').get(token=token_str)
30+
except CalendarToken.DoesNotExist:
31+
raise Http403()
32+
33+
user = calendar_token.user
34+
35+
if settings.OAUTH_ENABLED:
36+
from shiftings.accounts.models import OIDCOfflineToken
37+
try:
38+
offline_token = OIDCOfflineToken.objects.get(user=user)
39+
if offline_token.updated < timezone.now() - OIDC_REFRESH_COOLDOWN:
40+
success = offline_token.refresh_user_info()
41+
if not success:
42+
return HttpResponse(
43+
'OIDC token expired. Please log in via browser to renew.',
44+
status=403,
45+
content_type='text/plain',
46+
)
47+
user.refresh_from_db()
48+
except OIDCOfflineToken.DoesNotExist:
49+
return HttpResponse(
50+
'No OIDC token stored. Please log in via browser.',
51+
status=403,
52+
content_type='text/plain',
53+
)
54+
55+
request.user = user
56+
return super().__call__(request, *args, **kwargs)
57+
58+
59+
class TokenOwnShiftsFeed(TokenAuthMixin, OwnShiftsFeed):
60+
pass

src/shiftings/cal/urls/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from django.urls import path
22

3+
from shiftings.cal.feed.token import TokenOwnShiftsFeed
34
from shiftings.cal.views.day_calendar import DetailDayView, ShiftTypesDayView
45
from shiftings.cal.views.month_calendar import MonthCalenderView
56
from shiftings.cal.views.list import DetailListView, ShiftTypesListView
67

78
urlpatterns = [
9+
path('token/<str:token>/participations/', TokenOwnShiftsFeed(), name='token_participation_calendar'),
810
path('overview/day/detail/', DetailDayView.as_view(), name='overview_today'),
911
path('overview/day/detail/<theday>/', DetailDayView.as_view(), name='overview_day'),
1012
path('overview/day/shift_types/', ShiftTypesDayView.as_view(), name='overview_today_shift_types'),

0 commit comments

Comments
 (0)