(back) add subscription and iCal API

Add API endpoints for calendar subscription token management
and iCal export. Includes serializers, viewsets and URL
configuration for subscription URLs and .ics file generation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-25 20:33:11 +01:00
parent 7e90c960dc
commit f329de9354
6 changed files with 486 additions and 16 deletions

View File

@@ -5,12 +5,15 @@ import secrets
from django.conf import settings
from django.http import HttpResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
import requests
from core.services.calendar_invitation_service import calendar_invitation_service
logger = logging.getLogger(__name__)
@@ -31,7 +34,7 @@ class CalDAVProxyView(View):
if request.method == "OPTIONS":
response = HttpResponse(status=200)
response["Access-Control-Allow-Methods"] = (
"GET, OPTIONS, PROPFIND, REPORT, MKCOL, MKCALENDAR, PUT, DELETE"
"GET, OPTIONS, PROPFIND, PROPPATCH, REPORT, MKCOL, MKCALENDAR, PUT, DELETE, POST"
)
response["Access-Control-Allow-Headers"] = (
"Content-Type, depth, authorization, if-match, if-none-match, prefer"
@@ -81,6 +84,21 @@ class CalDAVProxyView(View):
headers["X-Api-Key"] = outbound_api_key
# Add callback URL for CalDAV scheduling (iTip/iMip)
# The CalDAV server will call this URL when it needs to send invitations
# Use CALDAV_CALLBACK_BASE_URL if configured (for Docker environments where
# the CalDAV container needs to reach Django via internal network)
callback_path = reverse("caldav-scheduling-callback")
callback_base_url = getattr(settings, "CALDAV_CALLBACK_BASE_URL", None)
if callback_base_url:
# Use configured internal URL (e.g., http://backend:8000)
headers["X-CalDAV-Callback-URL"] = (
f"{callback_base_url.rstrip('/')}{callback_path}"
)
else:
# Fall back to external URL (works when CalDAV can reach Django externally)
headers["X-CalDAV-Callback-URL"] = request.build_absolute_uri(callback_path)
# No Basic Auth - our custom backend uses X-Forwarded-User header and API key
auth = None
@@ -116,13 +134,12 @@ class CalDAVProxyView(View):
allow_redirects=False,
)
# Log authentication failures for debugging
# Log authentication failures for debugging (without sensitive headers)
if response.status_code == 401:
logger.warning(
"CalDAV server returned 401 for user %s at %s. Headers sent: %s",
"CalDAV server returned 401 for user %s at %s",
user_principal,
target_url,
headers,
)
# Build Django response
@@ -188,7 +205,13 @@ class CalDAVSchedulingCallbackView(View):
Endpoint for receiving CalDAV scheduling messages (iMip) from sabre/dav.
This endpoint receives scheduling messages (invites, responses, cancellations)
from the CalDAV server and processes them. Authentication is via API key.
from the CalDAV server and processes them by sending email notifications
with ICS attachments. Authentication is via API key.
Supported iTip methods (RFC 5546):
- REQUEST: New invitation or event update
- CANCEL: Event cancellation
- REPLY: Attendee response (accept/decline/tentative)
See: https://sabre.io/dav/scheduling/
"""
@@ -211,21 +234,80 @@ class CalDAVSchedulingCallbackView(View):
# Extract headers
sender = request.headers.get("X-CalDAV-Sender", "")
recipient = request.headers.get("X-CalDAV-Recipient", "")
method = request.headers.get("X-CalDAV-Method", "")
method = request.headers.get("X-CalDAV-Method", "").upper()
# Validate required fields
if not sender or not recipient or not method:
logger.error(
"CalDAV scheduling callback missing required headers: "
"sender=%s, recipient=%s, method=%s",
sender,
recipient,
method,
)
return HttpResponse(
status=400,
content="Missing required headers: X-CalDAV-Sender, X-CalDAV-Recipient, X-CalDAV-Method",
content_type="text/plain",
)
# Get iCalendar data from request body
icalendar_data = (
request.body.decode("utf-8", errors="replace") if request.body else ""
)
if not icalendar_data:
logger.error("CalDAV scheduling callback received empty body")
return HttpResponse(
status=400,
content="Missing iCalendar data in request body",
content_type="text/plain",
)
# For now, just log the scheduling message
logger.info(
"Received CalDAV scheduling callback: %s -> %s (method: %s)",
"Processing CalDAV scheduling message: %s -> %s (method: %s)",
sender,
recipient,
method,
)
# Log message body (first 500 chars)
if request.body:
body_preview = request.body[:500].decode("utf-8", errors="ignore")
logger.info("Scheduling message body (first 500 chars): %s", body_preview)
# Send the invitation/notification email
try:
success = calendar_invitation_service.send_invitation(
sender_email=sender,
recipient_email=recipient,
method=method,
icalendar_data=icalendar_data,
)
# TODO: Process the scheduling message (send email, update calendar, etc.)
# For now, just return success
return HttpResponse(status=200, content_type="text/plain")
if success:
logger.info(
"Successfully sent calendar %s email: %s -> %s",
method,
sender,
recipient,
)
return HttpResponse(
status=200,
content="OK",
content_type="text/plain",
)
else:
logger.error(
"Failed to send calendar %s email: %s -> %s",
method,
sender,
recipient,
)
return HttpResponse(
status=500,
content="Failed to send email",
content_type="text/plain",
)
except Exception as e:
logger.exception("Error processing CalDAV scheduling callback: %s", e)
return HttpResponse(
status=500,
content=f"Internal error: {str(e)}",
content_type="text/plain",
)