Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/pretalx/agenda/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ def get_schedule_urls(regex_prefix, name_prefix=""):
(".xcal", schedule.ExporterView.as_view(), "export.schedule.xcal"),
(".json", schedule.ExporterView.as_view(), "export.schedule.json"),
(".ics", schedule.ExporterView.as_view(), "export.schedule.ics"),
("/export/google-calendar", schedule.GoogleCalendarRedirectView.as_view(), "export.google-calendar"),
("/export/my-google-calendar", schedule.GoogleCalendarRedirectView.as_view(), "export.my-google-calendar"),
("/export/google-calendar", schedule.CalendarRedirectView.as_view(), "export.google-calendar"),
("/export/my-google-calendar", schedule.CalendarRedirectView.as_view(), "export.my-google-calendar"),
("/export/webcal", schedule.CalendarRedirectView.as_view(), "export.webcal"),
("/export/my-webcal", schedule.CalendarRedirectView.as_view(), "export.my-webcal"),
("/export/<name>", schedule.ExporterView.as_view(), "export"),
("/widgets/schedule.json", widget.widget_data, "widget.data"),
# Legacy widget data URL, but expected in old widget code.
Expand All @@ -45,6 +47,11 @@ def get_schedule_urls(regex_prefix, name_prefix=""):
widget.widget_script,
name="widget.script",
),
path(
"export/<str:name>/<str:token>/",
schedule.ExporterView.as_view(),
name="export-tokenized",
),
path("static/event.css", widget.event_css, name="event.css"),
path(
"schedule/changelog/",
Expand Down
162 changes: 130 additions & 32 deletions src/pretalx/agenda/views/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import textwrap
from contextlib import suppress
from urllib.parse import unquote, urlencode, urlparse, urlunparse
from datetime import timedelta

from django.contrib import messages
from django.core import signing
from django.utils import timezone
from django.http import (
Http404,
HttpResponse,
Expand Down Expand Up @@ -65,6 +68,42 @@ def dispatch(self, request, *args, **kwargs):
)
return super().dispatch(request, *args, **kwargs)

@staticmethod
def generate_ics_token(user_id):
"""Generate a signed token with user ID and 15-day expiry"""
expiry = timezone.now() + timedelta(days=15)
value = {"user_id": user_id, "exp": int(expiry.timestamp())}
return signing.dumps(value, salt="my-starred-ics")

@staticmethod
def parse_ics_token(token):
"""Parse and validate the token, return user_id if valid"""
try:
value = signing.loads(token, salt="my-starred-ics", max_age=15*24*60*60)
if value["exp"] < int(timezone.now().timestamp()):
raise ValueError("Token expired")
return value["user_id"]
except (signing.BadSignature, signing.SignatureExpired, KeyError, ValueError) as e:
logger.warning('Failed to parse ICS token: %s', e)
return None

@staticmethod
def check_token_expiry(token):
"""Check if a token exists and has more than 4 days until expiry

Returns:
- None if token is invalid
- False if token is valid but expiring soon (< 4 days)
- True if token is valid and not expiring soon (>= 4 days)
"""
try:
value = signing.loads(token, salt="my-starred-ics")
expiry_date = timezone.datetime.fromtimestamp(value["exp"], tz=timezone.utc)
time_until_expiry = expiry_date - timezone.now()
return time_until_expiry >= timedelta(days=4)
except Exception as e:
logger.warning('Failed to check token expiry: %s', e)
return None # Invalid token

class ExporterView(EventPermissionRequired, ScheduleMixin, TemplateView):
permission_required = "agenda.view_schedule"
Expand All @@ -88,12 +127,12 @@ def get_context_data(self, **kwargs):
def get_exporter(self, public=True):
url = resolve(self.request.path_info)

if url.url_name == "export":
calendar_exports = ["export.google-calendar", "export.my-google-calendar", "export.other-calendar", "export.my-other-calendar"]
if url.url_name in ["export", "export-tokenized"]:
exporter = url.kwargs.get("name") or unquote(
self.request.GET.get("exporter")
)
elif url.url_name in ["export.google-calendar", "export.my-google-calendar"]:
# Handle our explicit Google Calendar URL patterns
elif url.url_name in calendar_exports:
exporter = url.url_name.replace("export.", "")
else:
exporter = url.url_name
Expand All @@ -118,19 +157,33 @@ def get(self, request, *args, **kwargs):
elif "lang" in request.GET:
activate(request.event.locale)

exporter.schedule = self.schedule
if "-my" in exporter.identifier and self.request.user.id is None:
# Handle tokenized access for Google Calendar integration
token = kwargs.get('token')
if token and "-my" in exporter.identifier:
user_id = ScheduleMixin.parse_ics_token(token)
if not user_id:
raise Http404()

# Set up exporter for this user without requiring login
favs_talks = SubmissionFavourite.objects.filter(user=user_id)
if favs_talks.exists():
exporter.talk_ids = list(
favs_talks.values_list("submission_id", flat=True)
)
elif "-my" in exporter.identifier and self.request.user.id is None:
if request.GET.get("talks"):
exporter.talk_ids = request.GET.get("talks").split(",")
else:
return HttpResponseRedirect(self.request.event.urls.login)
favs_talks = SubmissionFavourite.objects.filter(
user=self.request.user.id
)
if favs_talks.exists():
exporter.talk_ids = list(
favs_talks.values_list("submission_id", flat=True)
elif "-my" in exporter.identifier:
favs_talks = SubmissionFavourite.objects.filter(
user=self.request.user.id
)
if favs_talks.exists():
exporter.talk_ids = list(
favs_talks.values_list("submission_id", flat=True)
)

exporter.is_orga = getattr(self.request, "is_orga", False)

try:
Expand Down Expand Up @@ -306,30 +359,75 @@ class ChangelogView(EventPermissionRequired, TemplateView):
permission_required = "agenda.view_schedule"


class GoogleCalendarRedirectView(EventPermissionRequired, ScheduleMixin, TemplateView):
class CalendarRedirectView(EventPermissionRequired, ScheduleMixin, TemplateView):
"""Handles redirects for both Google Calendar and other calendar applications"""
MY_STARRED_ICS_TOKEN_SESSION_KEY = 'my_starred_ics_token'
permission_required = "agenda.view_schedule"

def get(self, request, *args, **kwargs):
# Use resolver_match.url_name for robust route detection
# Get URL name from resolver
url_name = request.resolver_match.url_name if request.resolver_match else None
if url_name == 'export.my-google-calendar':
ics_name = 'schedule-my.ics'
else:
ics_name = 'schedule.ics'

# Build the iCal URL
ics_url = request.build_absolute_uri(
reverse('agenda:export', kwargs={
'event': self.request.event.slug,
'name': ics_name
})
)

# Change scheme to webcal
parsed = urlparse(ics_url)
ics_url = urlunparse(('webcal',) + parsed[1:])

# Create Google Calendar URL
google_url = f"https://calendar.google.com/calendar/render?{urlencode({'cid': ics_url})}"
# Determine calendar type and starred status from URL pattern
is_google = "google" in url_name
is_my = "my" in url_name

if is_my:
# For starred sessions
if not request.user.is_authenticated:
return HttpResponseRedirect(self.request.event.urls.login)

# Check for existing valid token
existing_token = request.session.get(self.MY_STARRED_ICS_TOKEN_SESSION_KEY)
generate_new_token = True

# If we have an existing token, check if it's still valid and not expiring soon
if existing_token:
token_status = self.check_token_expiry(existing_token)
if token_status is True:
token = existing_token
generate_new_token = False

# Generate new token if needed
if generate_new_token:
token = self.generate_ics_token(request.user.id)
request.session[self.MY_STARRED_ICS_TOKEN_SESSION_KEY] = token

# Build tokenized URL for starred sessions
ics_url = request.build_absolute_uri(
reverse('agenda:export-tokenized', kwargs={
'event': self.request.event.slug,
'name': 'schedule-my.ics',
'token': token
})
)
else:
# Build public calendar URL
ics_url = request.build_absolute_uri(
reverse('agenda:export', kwargs={
'event': self.request.event.slug,
'name': 'schedule.ics'
})
)

return HttpResponseRedirect(google_url)
# Handle redirect based on calendar type
if is_google:
# Google Calendar requires special URL format
google_url = f"https://calendar.google.com/calendar/render?{urlencode({'cid': ics_url})}"
response = HttpResponse(
f'<html><head><meta http-equiv="refresh" content="0;url={google_url}"></head>'
f'<body><p style="text-align: center; padding:2vw; font-family: Roboto,Helvetica Neue,HelveticaNeue,Helvetica,Arial,sans-serif;">Redirecting to Google Calendar: {google_url}</p><script>window.location.href="{google_url}";</script></body></html>',
content_type='text/html'
)
return response
else:
# Other calendars use webcal protocol
parsed = urlparse(ics_url)
webcal_url = urlunparse(('webcal',) + parsed[1:])
# Create a simple HTML redirect with meta refresh
response = HttpResponse(
f'<html><head><meta http-equiv="refresh" content="0;url={webcal_url}"></head>'
f'<body><p style="text-align: center; padding:2vw; font-family: Roboto,Helvetica Neue,HelveticaNeue,Helvetica,Arial,sans-serif;">Redirecting to: {webcal_url}</p><script>window.location.href="{webcal_url}";</script></body></html>',
content_type='text/html'
)
return response
21 changes: 17 additions & 4 deletions src/pretalx/schedule/exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,20 +429,33 @@ def render(self, request, **kwargs):
return f"{self.event.slug}-favs.ics", "text/calendar", cal.serialize()


class BaseGoogleCalendarExporter(BaseExporter):
class BaseCalendarExporter(BaseExporter):
public = True
show_qrcode = False
icon = "fa-google"
icon = "fa-calendar"

@property
def show_public(self):
return self.ical_exporter_cls(self.event).show_public

class GoogleCalendarExporter(BaseGoogleCalendarExporter):
class GoogleCalendarExporter(BaseCalendarExporter):
identifier = "google-calendar"
verbose_name = "Add to Google Calendar"
icon = "fa-google"
ical_exporter_cls = ICalExporter

class MyGoogleCalendarExporter(BaseGoogleCalendarExporter):
class MyGoogleCalendarExporter(BaseCalendarExporter):
identifier = "my-google-calendar"
icon = "fa-google"
verbose_name = "Add My ⭐ Sessions to Google Calendar"
ical_exporter_cls = MyICalExporter

class WebcalExporter(BaseCalendarExporter):
identifier = "webcal"
verbose_name = "Add to Other Calendar"
ical_exporter_cls = ICalExporter

class MyWebcalExporter(BaseCalendarExporter):
identifier = "my-webcal"
verbose_name = "Add My ⭐ Sessions to Other Calendar"
ical_exporter_cls = MyICalExporter
10 changes: 10 additions & 0 deletions src/pretalx/schedule/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,13 @@ def register_google_calendar_exporter(sender, **kwargs):
def register_my_google_calendar_exporter(sender, **kwargs):
from .exporters import MyGoogleCalendarExporter
return MyGoogleCalendarExporter

@receiver(register_data_exporters, dispatch_uid="exporter_builtin_webcal")
def register_webcal_exporter(sender, **kwargs):
from .exporters import WebcalExporter
return WebcalExporter

@receiver(register_my_data_exporters, dispatch_uid="exporter_builtin_my_webcal")
def register_my_webcal_exporter(sender, **kwargs):
from .exporters import MyWebcalExporter
return MyWebcalExporter