(invitations) add invitation RSVP links in HTML emails (#10)

Also include many fixes and scalingo deployment
This commit is contained in:
Sylvain Zimmer
2026-02-19 18:15:47 +01:00
committed by GitHub
parent acce8d1425
commit 81954a4ead
37 changed files with 2294 additions and 511 deletions

View File

@@ -80,13 +80,20 @@ Yarn workspaces monorepo:
### CalDAV Server (`docker/sabredav/`)
PHP SabreDAV server providing CalDAV protocol support, running against the shared PostgreSQL database.
### Service Ports (Development)
- Frontend: http://localhost:8920
- Backend API: http://localhost:8921
- CalDAV: http://localhost:8922
- Keycloak: http://localhost:8925
- PostgreSQL: 8926
- Mailcatcher: http://localhost:1081
**IMPORTANT: Never query the SabreDAV database tables directly from Django.** Always interact with CalDAV through the SabreDAV HTTP API (PROPFIND, REPORT, PUT, etc.).
### Development Services
| Service | URL / Port | Description |
|---------|------------|-------------|
| **Frontend** | [http://localhost:8920](http://localhost:8920) | Next.js Calendar frontend |
| **Backend API** | [http://localhost:8921](http://localhost:8921) | Django REST API |
| **CalDAV** | [http://localhost:8922](http://localhost:8922) | SabreDAV CalDAV server |
| **Nginx** | [http://localhost:8923](http://localhost:8923) | Reverse proxy (frontend + API) |
| **Redis** | 8924 | Cache and Celery broker |
| **Keycloak** | [http://localhost:8925](http://localhost:8925) | OIDC identity provider |
| **PostgreSQL** | 8926 | Database server |
| **Mailcatcher** | [http://localhost:8927](http://localhost:8927) | Email testing interface |
## Key Technologies

View File

@@ -10,11 +10,14 @@ if [ -n "$DATABASE_URL" ] && [ -z "$PGHOST" ]; then
eval "$(python3 -c "
import os, urllib.parse
u = urllib.parse.urlparse(os.environ['DATABASE_URL'])
qs = dict(urllib.parse.parse_qsl(u.query))
print(f'export PGHOST=\"{u.hostname}\"')
print(f'export PGPORT=\"{u.port or 5432}\"')
print(f'export PGDATABASE=\"{u.path.lstrip(\"/\")}\"')
print(f'export PGUSER=\"{u.username}\"')
print(f'export PGPASSWORD=\"{urllib.parse.unquote(u.password)}\"')
if 'sslmode' in qs:
print(f'export PGSSLMODE=\"{qs[\"sslmode\"]}\"')
")"
echo "-----> Parsed DATABASE_URL into PG* vars (host=$PGHOST port=$PGPORT db=$PGDATABASE)"
fi

View File

@@ -9,6 +9,8 @@ echo "-----> Running post-frontend script"
mkdir -p build/
mv src/frontend/apps/calendars/out build/frontend-out
cp src/frontend/apps/calendars/src/features/i18n/translations.json translations.json
mv src/backend/* ./
mv src/nginx/* ./
@@ -21,31 +23,72 @@ PHP_PREFIX=".php"
DEB_DIR="/tmp/php-debs"
mkdir -p "$DEB_DIR" "$PHP_PREFIX"
BASE_URL="http://security.ubuntu.com/ubuntu/pool/main/p/php8.3"
VERSION="8.3.6-0ubuntu0.24.04.6"
# Hardcoded Launchpad URLs for PHP 8.3.6-0maysync1 (Ubuntu Noble amd64)
# Source: https://launchpad.net/ubuntu/noble/amd64/php8.3-fpm/8.3.6-0maysync1
declare -A PHP_DEBS=(
[php8.3-cli]="http://launchpadlibrarian.net/724872605/php8.3-cli_8.3.6-0maysync1_amd64.deb"
[php8.3-fpm]="http://launchpadlibrarian.net/724872610/php8.3-fpm_8.3.6-0maysync1_amd64.deb"
[php8.3-common]="http://launchpadlibrarian.net/724872606/php8.3-common_8.3.6-0maysync1_amd64.deb"
[php8.3-opcache]="http://launchpadlibrarian.net/724872623/php8.3-opcache_8.3.6-0maysync1_amd64.deb"
[php8.3-readline]="http://launchpadlibrarian.net/724872627/php8.3-readline_8.3.6-0maysync1_amd64.deb"
[php8.3-pgsql]="http://launchpadlibrarian.net/724872624/php8.3-pgsql_8.3.6-0maysync1_amd64.deb"
[php8.3-xml]="http://launchpadlibrarian.net/724872633/php8.3-xml_8.3.6-0maysync1_amd64.deb"
[php8.3-mbstring]="http://launchpadlibrarian.net/724872617/php8.3-mbstring_8.3.6-0maysync1_amd64.deb"
[php8.3-curl]="http://launchpadlibrarian.net/724872607/php8.3-curl_8.3.6-0maysync1_amd64.deb"
[php-common]="http://launchpadlibrarian.net/710804987/php-common_93ubuntu2_all.deb"
)
for pkg in cli fpm common opcache readline pgsql xml mbstring curl; do
echo " Downloading php8.3-${pkg}"
curl -fsSL -o "$DEB_DIR/php8.3-${pkg}.deb" \
"${BASE_URL}/php8.3-${pkg}_${VERSION}_amd64.deb"
for pkg in "${!PHP_DEBS[@]}"; do
echo " Downloading ${pkg}"
curl -fsSL -o "$DEB_DIR/${pkg}.deb" "${PHP_DEBS[$pkg]}"
done
curl -fsSL -o "$DEB_DIR/php-common.deb" \
"http://mirrors.kernel.org/ubuntu/pool/main/p/php-defaults/php-common_93ubuntu2_all.deb"
for deb in "$DEB_DIR"/*.deb; do
dpkg-deb -x "$deb" "$PHP_PREFIX"
done
# Create php wrapper
# Detect PHP extension directory (e.g. .php/usr/lib/php/20230831)
EXT_DIR_NAME="$(ls -1 "$PHP_PREFIX/usr/lib/php/" | grep '^20' | head -1)"
echo " Extension API dir: ${EXT_DIR_NAME}"
echo " Available .so files: $(ls "$PHP_PREFIX/usr/lib/php/$EXT_DIR_NAME/" 2>/dev/null | tr '\n' ' ')"
# Build a single php.ini that sets extension_dir (relative to /app at runtime)
# then loads every shared extension present.
# Conf.d symlinks from debs are broken (absolute paths to /etc/php/...),
# so we bypass them entirely with a self-contained ini.
PHP_INI="$PHP_PREFIX/php.ini"
{
echo "; Auto-generated PHP config"
echo "extension_dir = /app/.php/usr/lib/php/${EXT_DIR_NAME}"
echo ""
for so in "$PHP_PREFIX/usr/lib/php/$EXT_DIR_NAME"/*.so; do
[ -f "$so" ] || continue
name="$(basename "$so")"
if [ "$name" = "opcache.so" ]; then
echo "zend_extension = ${name}"
else
echo "extension = ${name}"
fi
done
} > "$PHP_INI"
echo " Generated php.ini:"
cat "$PHP_INI" | sed 's/^/ /'
# Create a build-time copy with the current path (not /app)
BUILD_INI="/tmp/php-build.ini"
sed "s|/app/.php|$PWD/.php|" "$PHP_INI" > "$BUILD_INI"
# Create php wrapper (uses /app php.ini at runtime)
cat > bin/php << 'WRAPPER'
#!/bin/bash
DIR="$(cd "$(dirname "$0")/.." && pwd)"
PHP_INI_SCAN_DIR="$DIR/.php/etc/php/8.3/cli/conf.d" \
exec "$DIR/.php/usr/bin/php8.3" "$@"
exec "$DIR/.php/usr/bin/php8.3" -c "$DIR/.php/php.ini" -n "$@"
WRAPPER
chmod +x bin/php
echo "-----> PHP version: $(bin/php -v | head -1)"
# For build-time, verify with the build-time ini
echo "-----> PHP version: $("$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" -v | head -1)"
echo "-----> PHP modules: $("$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" -m | tr '\n' ' ')"
# Download Composer and install SabreDAV dependencies
echo "-----> Installing SabreDAV dependencies"
@@ -53,6 +96,6 @@ curl -fsSL -o bin/composer.phar \
https://getcomposer.org/download/latest-stable/composer.phar
cp -r docker/sabredav sabredav
cd sabredav
../bin/php ../bin/composer.phar install \
"../$PHP_PREFIX/usr/bin/php8.3" -n -c "$BUILD_INI" ../bin/composer.phar install \
--no-dev --optimize-autoloader --no-interaction
cd ..

View File

@@ -4,8 +4,8 @@
source bin/export_pg_vars.sh
# Start PHP-FPM for SabreDAV (CalDAV server)
PHP_INI_SCAN_DIR=/app/.php/etc/php/8.3/cli/conf.d \
.php/usr/sbin/php-fpm8.3 \
.php/usr/sbin/php-fpm8.3 \
-n -c /app/.php/php.ini \
--fpm-config /app/sabredav/php-fpm.conf \
--nodaemonize &

View File

@@ -18,12 +18,12 @@ services:
redis:
image: redis:5
ports:
- "6379:6379"
- "8924:6379"
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "1081:1080"
- "8927:1080"
backend-dev:
build:
@@ -44,6 +44,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- ./src/frontend/apps/calendars/src/features/i18n/translations.json:/data/translations.json:ro
- /app/.venv
networks:
- lasuite
@@ -76,6 +77,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- ./src/frontend/apps/calendars/src/features/i18n/translations.json:/data/translations.json:ro
- /app/.venv
nginx:

View File

@@ -12,6 +12,23 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# RSVP and iCal routes - proxy to Django backend
location /rsvp/ {
proxy_pass http://backend-dev:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ical/ {
proxy_pass http://backend-dev:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend - proxy to Next.js dev server
location / {
proxy_pass http://frontend-dev:3000;

View File

@@ -21,7 +21,7 @@ require_once __DIR__ . '/vendor/autoload.php';
// Get base URI from environment variable (set by compose.yaml)
// This ensures sabre/dav generates URLs with the correct proxy path
$baseUri = getenv('CALENDARS_BASE_URI') ?: '/';
$baseUri = getenv('CALDAV_BASE_URI') ?: '/';
// Database connection from environment variables
$dbHost = getenv('PGHOST') ?: 'postgresql';

View File

@@ -3,7 +3,7 @@ PGPORT=5432
PGDATABASE=calendars
PGUSER=pgroot
PGPASSWORD=pass
CALENDARS_BASE_URI=/api/v1.0/caldav/
CALDAV_BASE_URI=/api/v1.0/caldav/
CALDAV_INBOUND_API_KEY=changeme-inbound-in-production
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
# Default callback URL for sending scheduling notifications (emails)

View File

@@ -119,6 +119,14 @@ class Base(Configuration):
)
APP_NAME = values.Value("Calendrier", environ_name="APP_NAME", environ_prefix=None)
APP_URL = values.Value("", environ_name="APP_URL", environ_prefix=None)
CALENDAR_ITIP_ENABLED = values.BooleanValue(
False, environ_name="CALENDAR_ITIP_ENABLED", environ_prefix=None
)
TRANSLATIONS_JSON_PATH = values.Value(
"/data/translations.json",
environ_name="TRANSLATIONS_JSON_PATH",
environ_prefix=None,
)
# Security
ALLOWED_HOSTS = values.ListValue([])
@@ -857,7 +865,7 @@ class Development(Base):
DEFAULT_FROM_EMAIL = "calendars@calendars.world"
CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world"
APP_NAME = "Calendrier (Dev)"
APP_URL = "http://localhost:8920"
APP_URL = "http://localhost:8921"
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
@@ -945,6 +953,7 @@ class Production(Base):
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
r"^api/v1\.0/caldav-scheduling-callback/",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`

View File

@@ -3,8 +3,6 @@
import json
import logging
import re
from urllib.parse import unquote
from django.conf import settings
from django.core.cache import cache
@@ -19,7 +17,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.throttling import UserRateThrottle
from core import models
from core.services.caldav_service import CalendarService
from core.services.caldav_service import (
CalendarService,
normalize_caldav_path,
verify_caldav_access,
)
from core.services.import_service import MAX_FILE_SIZE, ICSImportService
from . import permissions, serializers
@@ -261,47 +263,6 @@ class ConfigView(drf.views.APIView):
return theme_customization
# Regex for CalDAV path validation (shared with SubscriptionTokenViewSet)
# Pattern: /calendars/<email-or-encoded>/<calendar-id>/
CALDAV_PATH_PATTERN = re.compile(
r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$",
)
def _verify_caldav_access(user, caldav_path):
"""Verify that the user has access to the CalDAV calendar.
Checks that:
1. The path matches the expected pattern (prevents path injection)
2. The user's email matches the email in the path
"""
if not CALDAV_PATH_PATTERN.match(caldav_path):
return False
parts = caldav_path.strip("/").split("/")
if len(parts) >= 2 and parts[0] == "calendars":
path_email = unquote(parts[1])
return path_email.lower() == user.email.lower()
return False
def _normalize_caldav_path(caldav_path):
"""Normalize CalDAV path to consistent format.
Strips the CalDAV API prefix (e.g. /api/v1.0/caldav/) if present,
so that paths like /api/v1.0/caldav/calendars/user@ex.com/uuid/
become /calendars/user@ex.com/uuid/.
"""
if not caldav_path.startswith("/"):
caldav_path = "/" + caldav_path
# Strip CalDAV API prefix — keep from /calendars/ onwards
calendars_idx = caldav_path.find("/calendars/")
if calendars_idx > 0:
caldav_path = caldav_path[calendars_idx:]
if not caldav_path.endswith("/"):
caldav_path = caldav_path + "/"
return caldav_path
class CalendarViewSet(viewsets.GenericViewSet):
"""ViewSet for calendar operations.
@@ -354,10 +315,10 @@ class CalendarViewSet(viewsets.GenericViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
caldav_path = _normalize_caldav_path(caldav_path)
caldav_path = normalize_caldav_path(caldav_path)
# Verify user access
if not _verify_caldav_access(request.user, caldav_path):
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
@@ -429,7 +390,7 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet):
calendar_name = create_serializer.validated_data.get("calendar_name", "")
# Verify user has access to this calendar
if not _verify_caldav_access(request.user, caldav_path):
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,
@@ -468,10 +429,10 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
caldav_path = _normalize_caldav_path(caldav_path)
caldav_path = normalize_caldav_path(caldav_path)
# Verify user has access to this calendar
if not _verify_caldav_access(request.user, caldav_path):
if not verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN,

View File

@@ -12,6 +12,7 @@ from django.views.decorators.csrf import csrf_exempt
import requests
from core.services.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path
from core.services.calendar_invitation_service import calendar_invitation_service
logger = logging.getLogger(__name__)
@@ -45,44 +46,37 @@ class CalDAVProxyView(View):
return HttpResponse(status=401)
# Build the CalDAV server URL
caldav_url = settings.CALDAV_URL
path = kwargs.get("path", "")
# Validate path to prevent traversal attacks
if not validate_caldav_proxy_path(path):
return HttpResponse(status=400, content="Invalid path")
# Use user email as the principal (CalDAV server uses email as username)
user_principal = request.user.email
# Build target URL - CalDAV server uses base URI /api/v1.0/caldav/
# The proxy receives requests at /api/v1.0/caldav/... and forwards them
# to the CalDAV server at the same path (sabre/dav expects requests at its base URI)
base_uri_path = "/api/v1.0/caldav"
http = CalDAVHTTPClient()
# Build target URL
clean_path = path.lstrip("/") if path else ""
# Construct target URL - always include the base URI path
if clean_path:
target_url = f"{caldav_url}{base_uri_path}/{clean_path}"
target_url = http.build_url(clean_path)
else:
# Root request - use base URI path
target_url = f"{caldav_url}{base_uri_path}/"
target_url = http.build_url("")
# Prepare headers for CalDAV server
# CalDAV server uses custom auth backend that requires X-Forwarded-User header and API key
headers = {
"Content-Type": request.content_type or "application/xml",
"X-Forwarded-User": user_principal,
"X-Forwarded-For": request.META.get("REMOTE_ADDR", ""),
"X-Forwarded-Host": request.get_host(),
"X-Forwarded-Proto": request.scheme,
}
# API key is required for authentication
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
if not outbound_api_key:
# Prepare headers — start with shared auth headers, add proxy-specific ones
try:
headers = CalDAVHTTPClient.build_base_headers(user_principal)
except ValueError:
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
return HttpResponse(
status=500, content="CalDAV authentication not configured"
)
headers["X-Api-Key"] = outbound_api_key
headers["Content-Type"] = request.content_type or "application/xml"
headers["X-Forwarded-For"] = request.META.get("REMOTE_ADDR", "")
headers["X-Forwarded-Host"] = request.get_host()
headers["X-Forwarded-Proto"] = request.scheme
# Add callback URL for CalDAV scheduling (iTip/iMip)
# The CalDAV server will call this URL when it needs to send invitations
@@ -130,7 +124,7 @@ class CalDAVProxyView(View):
headers=headers,
data=body,
auth=auth,
timeout=30,
timeout=CalDAVHTTPClient.DEFAULT_TIMEOUT,
allow_redirects=False,
)

View File

@@ -2,7 +2,6 @@
import logging
from django.conf import settings
from django.http import Http404, HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
@@ -12,6 +11,7 @@ from django.views.decorators.csrf import csrf_exempt
import requests
from core.models import CalendarSubscriptionToken
from core.services.caldav_service import CalDAVHTTPClient
logger = logging.getLogger(__name__)
@@ -50,69 +50,18 @@ class ICalExportView(View):
)
# Proxy to SabreDAV
caldav_url = settings.CALDAV_URL
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
if not outbound_api_key:
http = CalDAVHTTPClient()
try:
caldav_path = subscription.caldav_path.lstrip("/")
response = http.request(
"GET",
subscription.owner.email,
caldav_path,
query="export",
)
except ValueError:
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
return HttpResponse(status=500, content="iCal export not configured")
# Build the CalDAV export URL
# caldav_path is like "/calendars/user@example.com/calendar-uuid/"
# We need to call /api/v1.0/caldav/calendars/user@example.com/calendar-uuid?export
base_uri_path = "/api/v1.0/caldav"
caldav_path = subscription.caldav_path.lstrip("/")
target_url = f"{caldav_url}{base_uri_path}/{caldav_path}?export"
# Prepare headers for CalDAV server
headers = {
"X-Forwarded-User": subscription.owner.email,
"X-Api-Key": outbound_api_key,
}
try:
logger.debug(
"Proxying iCal export for caldav_path %s to %s",
subscription.caldav_path,
target_url,
)
response = requests.get(
target_url,
headers=headers,
timeout=30, # Balanced timeout: allows large calendars while preventing DoS
)
if response.status_code != 200:
logger.error(
"CalDAV server returned %d for iCal export: %s",
response.status_code,
response.content[:500],
)
return HttpResponse(
status=502,
content="Error generating calendar data",
content_type="text/plain",
)
# Return ICS response
django_response = HttpResponse(
content=response.content,
status=200,
content_type="text/calendar; charset=utf-8",
)
# Set filename for download (use calendar_name or fallback to "calendar")
display_name = subscription.calendar_name or "calendar"
safe_name = display_name.replace('"', '\\"')
django_response["Content-Disposition"] = (
f'attachment; filename="{safe_name}.ics"'
)
# Prevent caching of potentially sensitive data
django_response["Cache-Control"] = "no-store, private"
# Prevent token leakage via referrer
django_response["Referrer-Policy"] = "no-referrer"
return django_response
except requests.exceptions.RequestException as e:
logger.error("CalDAV server error during iCal export: %s", str(e))
return HttpResponse(
@@ -120,3 +69,34 @@ class ICalExportView(View):
content="Calendar server unavailable",
content_type="text/plain",
)
if response.status_code != 200:
logger.error(
"CalDAV server returned %d for iCal export: %s",
response.status_code,
response.content[:500],
)
return HttpResponse(
status=502,
content="Error generating calendar data",
content_type="text/plain",
)
# Return ICS response
django_response = HttpResponse(
content=response.content,
status=200,
content_type="text/calendar; charset=utf-8",
)
# Set filename for download (use calendar_name or fallback to "calendar")
display_name = subscription.calendar_name or "calendar"
safe_name = display_name.replace('"', '\\"')
django_response["Content-Disposition"] = (
f'attachment; filename="{safe_name}.ics"'
)
# Prevent caching of potentially sensitive data
django_response["Cache-Control"] = "no-store, private"
# Prevent token leakage via referrer
django_response["Referrer-Policy"] = "no-referrer"
return django_response

View File

@@ -0,0 +1,164 @@
"""RSVP view for handling invitation responses from email links."""
import logging
import re
from django.core.signing import BadSignature, Signer
from django.shortcuts import render
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from core.services.caldav_service import CalDAVHTTPClient
from core.services.translation_service import TranslationService
logger = logging.getLogger(__name__)
PARTSTAT_ICONS = {
"accepted": "&#9989;", # green check
"tentative": "&#10067;", # question mark
"declined": "&#10060;", # red cross
}
PARTSTAT_COLORS = {
"accepted": "#16a34a",
"tentative": "#d97706",
"declined": "#dc2626",
}
PARTSTAT_VALUES = {
"accepted": "ACCEPTED",
"tentative": "TENTATIVE",
"declined": "DECLINED",
}
def _render_error(request, message, lang="fr"):
"""Render the RSVP error page."""
t = TranslationService.t
return render(
request,
"rsvp/response.html",
{
"page_title": t("rsvp.error.title", lang),
"error": message,
"error_title": t("rsvp.error.invalidLink", lang),
"header_color": "#dc2626",
"lang": lang,
},
status=400,
)
def _is_event_past(icalendar_data):
"""Check if the event has already ended.
For recurring events without DTEND, falls back to DTSTART.
If the event has an RRULE, it is never considered past (the
recurrence may extend indefinitely).
"""
from core.services.calendar_invitation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
ICalendarParser,
)
vevent = ICalendarParser.extract_vevent_block(icalendar_data)
if not vevent:
return False
# Recurring events may have future occurrences — don't reject them
rrule, _ = ICalendarParser.extract_property_with_params(vevent, "RRULE")
if rrule:
return False
# Use DTEND if available, otherwise DTSTART
for prop in ("DTEND", "DTSTART"):
raw, params = ICalendarParser.extract_property_with_params(vevent, prop)
dt = ICalendarParser.parse_datetime(raw, params.get("TZID"))
if dt:
# Make timezone-aware if naive (assume UTC)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt < timezone.now()
return False
@method_decorator(csrf_exempt, name="dispatch")
class RSVPView(View):
"""Handle RSVP responses from invitation email links."""
def get(self, request): # noqa: PLR0911
"""Process an RSVP response."""
token = request.GET.get("token", "")
action = request.GET.get("action", "")
lang = TranslationService.resolve_language(request=request)
t = TranslationService.t
# Validate action
if action not in PARTSTAT_VALUES:
return _render_error(request, t("rsvp.error.invalidAction", lang), lang)
# Unsign token — tokens don't have a built-in expiry,
# but RSVPs are rejected once the event has ended (_is_event_past).
signer = Signer(salt="rsvp")
try:
payload = signer.unsign_object(token)
except BadSignature:
return _render_error(request, t("rsvp.error.invalidToken", lang), lang)
uid = payload.get("uid")
recipient_email = payload.get("email")
# Strip mailto: prefix (case-insensitive) in case it leaked into the token
organizer_email = re.sub(
r"^mailto:", "", payload.get("organizer", ""), flags=re.IGNORECASE
)
if not uid or not recipient_email or not organizer_email:
return _render_error(request, t("rsvp.error.invalidPayload", lang), lang)
http = CalDAVHTTPClient()
# Find the event in the organizer's CalDAV calendars
calendar_data, href = http.find_event_by_uid(organizer_email, uid)
if not calendar_data or not href:
return _render_error(request, t("rsvp.error.eventNotFound", lang), lang)
# Check if the event is already over
if _is_event_past(calendar_data):
return _render_error(request, t("rsvp.error.eventPast", lang), lang)
# Update the attendee's PARTSTAT
partstat = PARTSTAT_VALUES[action]
updated_data = CalDAVHTTPClient.update_attendee_partstat(
calendar_data, recipient_email, partstat
)
if not updated_data:
return _render_error(request, t("rsvp.error.notAttendee", lang), lang)
# PUT the updated event back to CalDAV
success = http.put_event(organizer_email, href, updated_data)
if not success:
return _render_error(request, t("rsvp.error.updateFailed", lang), lang)
# Extract event summary for display
from core.services.calendar_invitation_service import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
ICalendarParser,
)
summary = ICalendarParser.extract_property(calendar_data, "SUMMARY") or ""
label = t(f"rsvp.{action}", lang)
return render(
request,
"rsvp/response.html",
{
"page_title": label,
"heading": label,
"message": t("rsvp.responseSent", lang),
"status_icon": PARTSTAT_ICONS[action],
"header_color": PARTSTAT_COLORS[action],
"event_summary": summary,
"lang": lang,
},
)

View File

@@ -1,29 +1,186 @@
"""Services for CalDAV integration."""
import logging
import re
from datetime import date, datetime, timedelta
from typing import Optional
from urllib.parse import unquote
from uuid import uuid4
from django.conf import settings
from django.utils import timezone
import icalendar
import requests
import caldav as caldav_lib
from caldav import DAVClient
from caldav.elements.cdav import CalendarDescription
from caldav.elements.dav import DisplayName
from caldav.elements.ical import CalendarColor
from caldav.lib.error import NotFoundError
logger = logging.getLogger(__name__)
class CalDAVHTTPClient:
"""Low-level HTTP client for CalDAV server communication.
Centralizes header building, URL construction, API key validation,
and HTTP requests. All higher-level CalDAV consumers delegate to this.
"""
BASE_URI_PATH = "/api/v1.0/caldav"
DEFAULT_TIMEOUT = 30
def __init__(self):
self.base_url = settings.CALDAV_URL.rstrip("/")
@staticmethod
def get_api_key() -> str:
"""Return the outbound API key, raising ValueError if not configured."""
key = settings.CALDAV_OUTBOUND_API_KEY
if not key:
raise ValueError("CALDAV_OUTBOUND_API_KEY is not configured")
return key
@classmethod
def build_base_headers(cls, email: str) -> dict:
"""Build authentication headers for CalDAV requests."""
return {
"X-Api-Key": cls.get_api_key(),
"X-Forwarded-User": email,
}
def build_url(self, path: str, query: str = "") -> str:
"""Build a full CalDAV URL from a resource path.
Handles paths with or without the /api/v1.0/caldav prefix.
"""
# If the path already includes the base URI prefix, use it directly
if path.startswith(self.BASE_URI_PATH):
url = f"{self.base_url}{path}"
else:
clean_path = path.lstrip("/")
url = f"{self.base_url}{self.BASE_URI_PATH}/{clean_path}"
if query:
url = f"{url}?{query}"
return url
def request( # noqa: PLR0913
self,
method: str,
email: str,
path: str,
*,
query: str = "",
data=None,
extra_headers: dict | None = None,
timeout: int | None = None,
content_type: str | None = None,
) -> requests.Response:
"""Make an authenticated HTTP request to the CalDAV server."""
headers = self.build_base_headers(email)
if content_type:
headers["Content-Type"] = content_type
if extra_headers:
headers.update(extra_headers)
url = self.build_url(path, query)
return requests.request(
method=method,
url=url,
headers=headers,
data=data,
timeout=timeout or self.DEFAULT_TIMEOUT,
)
def get_dav_client(self, email: str) -> DAVClient:
"""Return a configured caldav.DAVClient for the given user email."""
headers = self.build_base_headers(email)
caldav_url = f"{self.base_url}{self.BASE_URI_PATH}/"
return DAVClient(
url=caldav_url,
username=None,
password=None,
timeout=self.DEFAULT_TIMEOUT,
headers=headers,
)
def find_event_by_uid(self, email: str, uid: str) -> tuple[str | None, str | None]:
"""Find an event by UID across all of the user's calendars.
Returns (ical_data, href) or (None, None).
"""
client = self.get_dav_client(email)
try:
principal = client.principal()
for cal in principal.calendars():
try:
event = cal.object_by_uid(uid)
return event.data, str(event.url.path)
except caldav_lib.error.NotFoundError:
continue
logger.warning("Event UID %s not found in user %s calendars", uid, email)
return None, None
except Exception:
logger.exception("CalDAV error looking up event %s", uid)
return None, None
def put_event(self, email: str, href: str, ical_data: str) -> bool:
"""PUT updated iCalendar data back to CalDAV. Returns True on success."""
try:
response = self.request(
"PUT",
email,
href,
data=ical_data.encode("utf-8"),
content_type="text/calendar; charset=utf-8",
)
if response.status_code in (200, 201, 204):
return True
logger.error(
"CalDAV PUT failed: %s %s",
response.status_code,
response.text[:500],
)
return False
except requests.exceptions.RequestException:
logger.exception("CalDAV PUT error for %s", href)
return False
@staticmethod
def update_attendee_partstat(
ical_data: str, email: str, new_partstat: str
) -> str | None:
"""Update the PARTSTAT of an attendee in iCalendar data.
Returns the modified iCalendar string, or None if attendee not found.
"""
cal = icalendar.Calendar.from_ical(ical_data)
updated = False
for component in cal.walk("VEVENT"):
for _name, attendee in component.property_items("ATTENDEE"):
attendee_val = str(attendee).lower()
if email.lower() in attendee_val:
attendee.params["PARTSTAT"] = icalendar.vText(new_partstat)
updated = True
if not updated:
return None
return cal.to_ical().decode("utf-8")
class CalDAVClient:
"""
Client for communicating with CalDAV server using the caldav library.
"""
def __init__(self):
self.base_url = settings.CALDAV_URL
# Set the base URI path as expected by the CalDAV server
self.base_uri_path = "/api/v1.0/caldav/"
self.timeout = 30
self._http = CalDAVHTTPClient()
self.base_url = self._http.base_url
def _get_client(self, user) -> DAVClient:
"""
@@ -32,33 +189,7 @@ class CalDAVClient:
The CalDAV server requires API key authentication via Authorization header
and X-Forwarded-User header for user identification.
"""
# CalDAV server base URL - include the base URI path that sabre/dav expects
# Remove trailing slash from base_url and base_uri_path to avoid double slashes
base_url_clean = self.base_url.rstrip("/")
base_uri_clean = self.base_uri_path.rstrip("/")
caldav_url = f"{base_url_clean}{base_uri_clean}/"
# Prepare headers
# API key is required for authentication
headers = {
"X-Forwarded-User": user.email,
}
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
if not outbound_api_key:
raise ValueError("CALDAV_OUTBOUND_API_KEY is not configured")
headers["X-Api-Key"] = outbound_api_key
# No username/password needed - authentication is via API key and X-Forwarded-User header
# Pass None to prevent the caldav library from trying Basic auth
return DAVClient(
url=caldav_url,
username=None,
password=None,
timeout=self.timeout,
headers=headers,
)
return self._http.get_dav_client(user.email)
def get_calendar_info(self, user, calendar_path: str) -> dict | None:
"""
@@ -72,18 +203,12 @@ class CalDAVClient:
calendar = client.calendar(url=calendar_url)
# Fetch properties
props = calendar.get_properties(
[
"{DAV:}displayname",
"{http://apple.com/ns/ical/}calendar-color",
"{urn:ietf:params:xml:ns:caldav}calendar-description",
]
[DisplayName(), CalendarColor(), CalendarDescription()]
)
name = props.get("{DAV:}displayname", "Calendar")
color = props.get("{http://apple.com/ns/ical/}calendar-color", "#3174ad")
description = props.get(
"{urn:ietf:params:xml:ns:caldav}calendar-description", ""
)
name = props.get(DisplayName.tag, "Calendar")
color = props.get(CalendarColor.tag, "#3174ad")
description = props.get(CalendarDescription.tag, "")
# Clean up color (CalDAV may return with alpha channel like #RRGGBBAA)
if color and len(color) == 9 and color.startswith("#"):
@@ -102,7 +227,9 @@ class CalDAVClient:
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
return None
def create_calendar(self, user, calendar_name: str, calendar_id: str) -> str:
def create_calendar(
self, user, calendar_name: str, calendar_id: str, color: str = ""
) -> str:
"""
Create a new calendar in CalDAV server for the given user.
Returns the CalDAV server path for the calendar.
@@ -114,6 +241,10 @@ class CalDAVClient:
# Create calendar using caldav library
calendar = principal.make_calendar(name=calendar_name)
# Set calendar color if provided
if color:
calendar.set_properties([CalendarColor(color)])
# CalDAV server calendar path format: /calendars/{username}/{calendar_id}/
# The caldav library returns a URL object, convert to string and extract path
calendar_url = str(calendar.url)
@@ -390,12 +521,12 @@ class CalendarService:
calendar_name = "Mon calendrier"
return self.caldav.create_calendar(user, calendar_name, calendar_id)
def create_calendar( # pylint: disable=unused-argument
def create_calendar(
self, user, name: str, color: str = "#3174ad"
) -> str:
"""Create a new calendar for a user. Returns the caldav_path."""
calendar_id = str(uuid4())
return self.caldav.create_calendar(user, name, calendar_id)
return self.caldav.create_calendar(user, name, calendar_id, color=color)
def get_events(self, user, caldav_path: str, start=None, end=None) -> list:
"""Get events from a calendar. Returns parsed event data."""
@@ -414,3 +545,75 @@ class CalendarService:
def delete_event(self, user, caldav_path: str, event_uid: str) -> None:
"""Delete an event."""
self.caldav.delete_event(user, caldav_path, event_uid)
# ---------------------------------------------------------------------------
# CalDAV path utilities
# ---------------------------------------------------------------------------
# Pattern: /calendars/<email-or-encoded>/<calendar-id>/
CALDAV_PATH_PATTERN = re.compile(
r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$",
)
def normalize_caldav_path(caldav_path):
"""Normalize CalDAV path to consistent format.
Strips the CalDAV API prefix (e.g. /api/v1.0/caldav/) if present,
so that paths like /api/v1.0/caldav/calendars/user@ex.com/uuid/
become /calendars/user@ex.com/uuid/.
"""
if not caldav_path.startswith("/"):
caldav_path = "/" + caldav_path
# Strip CalDAV API prefix — keep from /calendars/ onwards
calendars_idx = caldav_path.find("/calendars/")
if calendars_idx > 0:
caldav_path = caldav_path[calendars_idx:]
if not caldav_path.endswith("/"):
caldav_path = caldav_path + "/"
return caldav_path
def verify_caldav_access(user, caldav_path):
"""Verify that the user has access to the CalDAV calendar.
Checks that:
1. The path matches the expected pattern (prevents path injection)
2. The user's email matches the email in the path
"""
if not CALDAV_PATH_PATTERN.match(caldav_path):
return False
parts = caldav_path.strip("/").split("/")
if len(parts) >= 2 and parts[0] == "calendars":
path_email = unquote(parts[1])
return path_email.lower() == user.email.lower()
return False
def validate_caldav_proxy_path(path):
"""Validate that a CalDAV proxy path is safe.
Prevents path traversal attacks by rejecting paths with:
- Directory traversal sequences (../)
- Null bytes
- Paths that don't start with expected prefixes
"""
if not path:
return True # Empty path is fine (root request)
# Block directory traversal
if ".." in path:
return False
# Block null bytes
if "\x00" in path:
return False
# Path must start with a known CalDAV resource prefix
allowed_prefixes = ("calendars/", "principals/", ".well-known/")
clean = path.lstrip("/")
if clean and not any(clean.startswith(prefix) for prefix in allowed_prefixes):
return False
return True

View File

@@ -16,28 +16,14 @@ from datetime import timezone as dt_timezone
from email import encoders
from email.mime.base import MIMEBase
from typing import Optional
from urllib.parse import urlencode
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.core.signing import Signer
from django.template.loader import render_to_string
# French month and day names for date formatting
FRENCH_DAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
FRENCH_MONTHS = [
"",
"janvier",
"février",
"mars",
"avril",
"mai",
"juin",
"juillet",
"août",
"septembre",
"octobre",
"novembre",
"décembre",
]
from core.services.translation_service import TranslationService
logger = logging.getLogger(__name__)
@@ -201,7 +187,7 @@ class ICalendarParser:
# Extract basic properties from VEVENT block
uid = cls.extract_property(vevent_block, "UID")
summary = cls.extract_property(vevent_block, "SUMMARY") or "(Sans titre)"
summary = cls.extract_property(vevent_block, "SUMMARY") or ""
description = cls.extract_property(vevent_block, "DESCRIPTION")
location = cls.extract_property(vevent_block, "LOCATION")
url = cls.extract_property(vevent_block, "URL")
@@ -338,22 +324,27 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
return False
try:
# Resolve language for the recipient
lang = TranslationService.resolve_language(email=recipient)
t = TranslationService.t
summary = event.summary or t("email.noTitle", lang)
# Determine email type and get appropriate subject/content
if method == self.METHOD_CANCEL:
subject = self._get_cancel_subject(event)
subject = t("email.subject.cancel", lang, summary=summary)
template_prefix = "calendar_invitation_cancel"
elif method == self.METHOD_REPLY:
subject = self._get_reply_subject(event)
subject = t("email.subject.reply", lang, summary=summary)
template_prefix = "calendar_invitation_reply"
elif event.sequence > 0:
subject = self._get_update_subject(event)
subject = t("email.subject.update", lang, summary=summary)
template_prefix = "calendar_invitation_update"
else:
subject = self._get_invitation_subject(event)
subject = t("email.subject.invitation", lang, summary=summary)
template_prefix = "calendar_invitation"
# Build context for templates
context = self._build_template_context(event, method)
context = self._build_template_context(event, method, lang)
# Render email bodies
text_body = render_to_string(f"emails/{template_prefix}.txt", context)
@@ -380,52 +371,54 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
)
return False
def _get_invitation_subject(self, event: EventDetails) -> str:
"""Generate subject line for new invitation."""
return f"Invitation : {event.summary}"
def _get_update_subject(self, event: EventDetails) -> str:
"""Generate subject line for event update."""
return f"Invitation modifiée : {event.summary}"
def _get_cancel_subject(self, event: EventDetails) -> str:
"""Generate subject line for cancellation."""
return f"Annulé : {event.summary}"
def _get_reply_subject(self, event: EventDetails) -> str:
"""Generate subject line for attendee reply."""
return f"Réponse : {event.summary}"
def _format_date_french(self, dt: datetime) -> str:
"""Format a datetime in French (e.g., 'jeudi 23 janvier 2026')."""
day_name = FRENCH_DAYS[dt.weekday()]
month_name = FRENCH_MONTHS[dt.month]
return f"{day_name} {dt.day} {month_name} {dt.year}"
def _build_template_context(self, event: EventDetails, method: str) -> dict:
def _build_template_context( # pylint: disable=too-many-locals
self, event: EventDetails, method: str, lang: str = "fr"
) -> dict:
"""Build context dictionary for email templates."""
# Format dates for display in French
t = TranslationService.t
summary = event.summary or t("email.noTitle", lang)
# Format dates for display
if event.is_all_day:
start_str = self._format_date_french(event.dtstart)
start_str = TranslationService.format_date(event.dtstart, lang)
end_str = (
self._format_date_french(event.dtend) if event.dtend else start_str
TranslationService.format_date(event.dtend, lang)
if event.dtend
else start_str
)
time_str = "Toute la journée"
time_str = t("email.allDay", lang)
else:
time_format = "%H:%M"
start_str = self._format_date_french(event.dtstart)
start_str = TranslationService.format_date(event.dtstart, lang)
start_time = event.dtstart.strftime(time_format)
end_time = event.dtend.strftime(time_format) if event.dtend else ""
end_str = (
self._format_date_french(event.dtend) if event.dtend else start_str
TranslationService.format_date(event.dtend, lang)
if event.dtend
else start_str
)
time_str = f"{start_time} - {end_time}" if end_time else start_time
return {
organizer_display = event.organizer_name or event.organizer_email
attendee_display = event.attendee_name or event.attendee_email
# Determine email type key for content lookups
if method == self.METHOD_CANCEL:
type_key = "cancel"
elif method == self.METHOD_REPLY:
type_key = "reply"
elif event.sequence > 0:
type_key = "update"
else:
type_key = "invitation"
context = {
"event": event,
"summary": summary,
"method": method,
"organizer_display": event.organizer_name or event.organizer_email,
"attendee_display": event.attendee_name or event.attendee_email,
"lang": lang,
"organizer_display": organizer_display,
"attendee_display": attendee_display,
"start_date": start_str,
"end_date": end_str,
"time_str": time_str,
@@ -433,28 +426,100 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
"is_cancel": method == self.METHOD_CANCEL,
"app_name": getattr(settings, "APP_NAME", "Calendrier"),
"app_url": getattr(settings, "APP_URL", ""),
# Translated content blocks
"content": {
"title": t(f"email.{type_key}.title", lang),
"heading": t(f"email.{type_key}.heading", lang),
"body": t(
f"email.{type_key}.body",
lang,
organizer=organizer_display,
attendee=attendee_display,
),
"badge": t(f"email.{type_key}.badge", lang),
},
"labels": {
"when": t("email.labels.when", lang),
"until": t("email.labels.until", lang),
"location": t("email.labels.location", lang),
"videoConference": t("email.labels.videoConference", lang),
"organizer": t("email.labels.organizer", lang),
"attendee": t("email.labels.attendee", lang),
"description": t("email.labels.description", lang),
"wasScheduledFor": t("email.labels.wasScheduledFor", lang),
},
"actions": {
"accept": t("email.actions.accept", lang),
"maybe": t("email.actions.maybe", lang),
"decline": t("email.actions.decline", lang),
},
"instructions": t(f"email.instructions.{type_key}", lang),
"footer": t(
f"email.footer.{'invitation' if type_key == 'invitation' else 'notification'}",
lang,
appName=getattr(settings, "APP_NAME", "Calendrier"),
),
}
# Add RSVP links for REQUEST method (invitations and updates)
if method == self.METHOD_REQUEST:
signer = Signer(salt="rsvp")
# Strip mailto: prefix (case-insensitive) for shorter tokens
organizer = re.sub(
r"^mailto:", "", event.organizer_email, flags=re.IGNORECASE
)
token = signer.sign_object(
{
"uid": event.uid,
"email": event.attendee_email,
"organizer": organizer,
}
)
app_url = getattr(settings, "APP_URL", "")
base = f"{app_url}/rsvp/"
for action in ("accept", "tentative", "decline"):
partstat = {
"accept": "accepted",
"tentative": "tentative",
"decline": "declined",
}
context[f"rsvp_{action}_url"] = (
f"{base}?{urlencode({'token': token, 'action': partstat[action]})}"
)
return context
def _prepare_ics_attachment(self, icalendar_data: str, method: str) -> str:
"""
Prepare ICS content with correct METHOD for attachment.
Prepare ICS content for attachment.
The METHOD property must be in the VCALENDAR component, not VEVENT.
When CALENDAR_ITIP_ENABLED is True, sets the METHOD property so that
calendar clients show Accept/Decline buttons (standard iTIP flow).
When False (default), strips METHOD so the ICS is treated as a plain
calendar object — our own RSVP web links handle responses instead.
"""
# Check if METHOD is already present
if "METHOD:" not in icalendar_data.upper():
# Insert METHOD after VERSION
icalendar_data = re.sub(
r"(VERSION:2\.0\r?\n)",
rf"\1METHOD:{method}\r\n",
icalendar_data,
flags=re.IGNORECASE,
)
itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False)
if itip_enabled:
if "METHOD:" not in icalendar_data.upper():
icalendar_data = re.sub(
r"(VERSION:2\.0\r?\n)",
rf"\1METHOD:{method}\r\n",
icalendar_data,
flags=re.IGNORECASE,
)
else:
icalendar_data = re.sub(
r"METHOD:[^\r\n]+",
f"METHOD:{method}",
icalendar_data,
flags=re.IGNORECASE,
)
else:
# Update existing METHOD
# Strip any existing METHOD so clients treat it as a plain event
icalendar_data = re.sub(
r"METHOD:[^\r\n]+",
f"METHOD:{method}",
r"METHOD:[^\r\n]+\r?\n",
"",
icalendar_data,
flags=re.IGNORECASE,
)
@@ -503,13 +568,14 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
email.attach_alternative(html_body, "text/html")
# Add ICS attachment with proper MIME type
# The Content-Type must include method parameter for calendar clients
ics_attachment = MIMEBase("text", "calendar")
ics_attachment.set_payload(ics_content.encode("utf-8"))
encoders.encode_base64(ics_attachment)
ics_attachment.add_header(
"Content-Type", f"text/calendar; charset=utf-8; method={ics_method}"
)
itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False)
content_type = "text/calendar; charset=utf-8"
if itip_enabled:
content_type += f"; method={ics_method}"
ics_attachment.add_header("Content-Type", content_type)
ics_attachment.add_header(
"Content-Disposition", 'attachment; filename="invite.ics"'
)

View File

@@ -3,10 +3,10 @@
import logging
from dataclasses import dataclass, field
from django.conf import settings
import requests
from core.services.caldav_service import CalDAVHTTPClient
logger = logging.getLogger(__name__)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
@@ -36,7 +36,7 @@ class ICSImportService:
"""
def __init__(self):
self.base_url = settings.CALDAV_URL.rstrip("/")
self._http = CalDAVHTTPClient()
def import_events(self, user, caldav_path: str, ics_data: bytes) -> ImportResult:
"""Import events from ICS data into a calendar.
@@ -53,30 +53,26 @@ class ICSImportService:
"""
result = ImportResult()
# Ensure caldav_path includes the base URI prefix that SabreDAV expects
base_uri = "/api/v1.0/caldav/"
if not caldav_path.startswith(base_uri):
caldav_path = base_uri.rstrip("/") + caldav_path
url = f"{self.base_url}{caldav_path}?import"
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
if not outbound_api_key:
try:
api_key = CalDAVHTTPClient.get_api_key()
except ValueError:
result.errors.append("CALDAV_OUTBOUND_API_KEY is not configured")
return result
headers = {
"Content-Type": "text/calendar",
"X-Api-Key": outbound_api_key,
"X-Forwarded-User": user.email,
"X-Calendars-Import": outbound_api_key,
}
# Timeout scales with file size: 60s base + 30s per MB of ICS data.
# 8000 events (~4MB) took ~70s in practice.
timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30
try:
# Timeout scales with file size: 60s base + 30s per MB of ICS data.
# 8000 events (~4MB) took ~70s in practice.
timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30
response = requests.post(
url, data=ics_data, headers=headers, timeout=timeout
response = self._http.request(
"POST",
user.email,
caldav_path,
query="import",
data=ics_data,
content_type="text/calendar",
extra_headers={"X-Calendars-Import": api_key},
timeout=timeout,
)
except requests.RequestException as exc:
logger.error("Failed to reach SabreDAV import endpoint: %s", exc)

View File

@@ -0,0 +1,152 @@
"""Translation service that loads translations from the shared translations.json."""
import json
import logging
import os
from datetime import datetime
from typing import Optional
from django.conf import settings
logger = logging.getLogger(__name__)
WEEKDAY_KEYS = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
]
MONTH_KEYS = [
"",
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
]
class TranslationService:
"""Lightweight translation service backed by translations.json."""
_translations = None
@classmethod
def _load(cls):
"""Load translations from JSON file (cached at class level)."""
if cls._translations is not None:
return
path = getattr(settings, "TRANSLATIONS_JSON_PATH", "")
if not path:
raise RuntimeError("TRANSLATIONS_JSON_PATH setting is not configured")
with open(path, encoding="utf-8") as f:
cls._translations = json.load(f)
@classmethod
def _get_nested(cls, data: dict, dotted_key: str):
"""Traverse a nested dict using dot-separated key."""
parts = dotted_key.split(".")
current = data
for part in parts:
if not isinstance(current, dict) or part not in current:
return None
current = current[part]
return current if isinstance(current, str) else None
@classmethod
def t(cls, key: str, lang: str = "en", **kwargs) -> str:
"""Look up a translation key with interpolation.
Fallback chain: lang -> "en" -> key itself.
Interpolation: ``{{var}}`` patterns are replaced from kwargs.
"""
cls._load()
for try_lang in (lang, "en"):
lang_data = cls._translations.get(try_lang, {})
translation_data = lang_data.get("translation", lang_data)
value = cls._get_nested(translation_data, key)
if value is not None:
for k, v in kwargs.items():
value = value.replace("{{" + k + "}}", str(v))
return value
return key
@classmethod
def resolve_language(cls, request=None, email: Optional[str] = None) -> str:
"""Determine the best language for a request or email recipient.
- From request: uses Django's get_language() (set by LocaleMiddleware).
- From email: looks up User.language field.
- Fallback: "fr".
"""
if request is not None:
from django.utils.translation import ( # noqa: PLC0415
get_language,
)
lang = get_language()
if lang:
return cls.normalize_lang(lang)
if email:
try:
from core.models import User # noqa: PLC0415
user = User.objects.filter(email=email).first()
if user and user.language:
return cls.normalize_lang(user.language)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to resolve language for email %s", email)
return "fr"
@staticmethod
def normalize_lang(lang_code: str) -> str:
"""Normalize a language code to a simple 2-letter code.
``"fr-fr"`` -> ``"fr"``, ``"en-us"`` -> ``"en"``, ``"nl-nl"`` -> ``"nl"``.
"""
if not lang_code:
return "fr"
short = lang_code.split("-")[0].lower()
return short if short in ("en", "fr", "nl") else "fr"
@classmethod
def format_date(cls, dt: datetime, lang: str) -> str:
"""Format a date using translated weekday/month names.
Returns e.g. "jeudi 23 janvier 2026" (fr)
or "Thursday, January 23, 2026" (en).
"""
weekday = cls.t(f"calendar.weekdaysFull.{WEEKDAY_KEYS[dt.weekday()]}", lang)
month = cls.t(f"calendar.recurrence.months.{MONTH_KEYS[dt.month]}", lang)
if lang == "fr":
month = month.lower()
return f"{weekday} {dt.day} {month} {dt.year}"
if lang == "nl":
month = month.lower()
return f"{weekday} {dt.day} {month} {dt.year}"
# English format
return f"{weekday}, {month} {dt.day}, {dt.year}"
@classmethod
def reset(cls):
"""Reset cached translations (useful for tests)."""
cls._translations = None

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invitation à un événement</title>
<title>{{ content.title }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
@@ -96,37 +96,37 @@
<body>
<div class="container">
<div class="header">
<h1>Invitation à un événement</h1>
<h1>{{ content.heading }}</h1>
</div>
<p>{{ organizer_display }} vous invite à un événement</p>
<p>{{ content.body }}</p>
<div class="event-title">{{ event.summary }}</div>
<div class="event-title">{{ summary }}</div>
<div class="event-details">
<table>
<tr>
<td>Quand</td>
<td>{{ labels.when }}</td>
<td>
{{ start_date }}<br>
<strong>{{ time_str }}</strong>
{% if start_date != end_date %}<br>jusqu'au {{ end_date }}{% endif %}
{% if start_date != end_date %}<br>{{ labels.until }} {{ end_date }}{% endif %}
</td>
</tr>
{% if event.location %}
<tr>
<td>Lieu</td>
<td>{{ labels.location }}</td>
<td>{{ event.location }}</td>
</tr>
{% endif %}
{% if event.url %}
<tr>
<td>Visio</td>
<td>{{ labels.videoConference }}</td>
<td><a href="{{ event.url }}" style="color: #0066cc;">{{ event.url }}</a></td>
</tr>
{% endif %}
<tr>
<td>Organisateur</td>
<td>{{ labels.organizer }}</td>
<td>{{ organizer_display }} &lt;{{ event.organizer_email }}&gt;</td>
</tr>
</table>
@@ -134,17 +134,24 @@
{% if event.description %}
<div class="description">
<h3>Description</h3>
<h3>{{ labels.description }}</h3>
<p>{{ event.description|linebreaks }}</p>
</div>
{% endif %}
{% if rsvp_accept_url %}
<div style="text-align: center; margin: 20px 0;">
<a href="{{ rsvp_accept_url }}" style="display: inline-block; padding: 10px 24px; margin: 0 6px; background-color: #16a34a; color: white; text-decoration: none; border-radius: 6px; font-weight: bold;">{{ actions.accept }}</a>
<a href="{{ rsvp_tentative_url }}" style="display: inline-block; padding: 10px 24px; margin: 0 6px; background-color: #d97706; color: white; text-decoration: none; border-radius: 6px; font-weight: bold;">{{ actions.maybe }}</a>
<a href="{{ rsvp_decline_url }}" style="display: inline-block; padding: 10px 24px; margin: 0 6px; background-color: #dc2626; color: white; text-decoration: none; border-radius: 6px; font-weight: bold;">{{ actions.decline }}</a>
</div>
{% endif %}
<div class="instructions">
<p>Pour répondre à cette invitation, veuillez ouvrir le fichier .ics en pièce jointe avec votre application de calendrier ou répondre directement depuis votre client de messagerie.</p>
<p>{{ instructions }}</p>
</div>
<div class="footer">
<p>Cette invitation a été envoyée via {{ app_name }}</p>
<p>{{ footer }}</p>
</div>
</div>
</body>

View File

@@ -1,20 +1,23 @@
{{ organizer_display }} vous invite à un événement
{{ content.body }}
Détails de l'événement
{{ summary }}
======================
Titre : {{ event.summary }}
Quand : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
{% if event.location %}Lieu : {{ event.location }}
{% endif %}{% if event.url %}Visio : {{ event.url }}
{% endif %}Organisateur : {{ organizer_display }} <{{ event.organizer_email }}>
{{ labels.when }} : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
{% if event.location %}{{ labels.location }} : {{ event.location }}
{% endif %}{% if event.url %}{{ labels.videoConference }} : {{ event.url }}
{% endif %}{{ labels.organizer }} : {{ organizer_display }} <{{ event.organizer_email }}>
{% if event.description %}
Description :
{{ labels.description }} :
{{ event.description }}
{% endif %}
Pour répondre à cette invitation, veuillez ouvrir le fichier .ics en pièce jointe avec votre application de calendrier ou répondre directement depuis votre client de messagerie.
{% if rsvp_accept_url %}{{ actions.accept }} : {{ rsvp_accept_url }}
{{ actions.maybe }} : {{ rsvp_tentative_url }}
{{ actions.decline }} : {{ rsvp_decline_url }}
{% endif %}{{ instructions }}
---
Cette invitation a été envoyée via {{ app_name }}
{{ footer }}

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Événement annulé</title>
<title>{{ content.title }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
@@ -89,50 +89,50 @@
<body>
<div class="container">
<div class="header">
<h1>Événement annulé</h1>
<h1>{{ content.heading }}</h1>
</div>
<span class="cancel-badge">ANNULÉ</span>
<span class="cancel-badge">{{ content.badge }}</span>
<p>{{ organizer_display }} a annulé l'événement suivant</p>
<p>{{ content.body }}</p>
<div class="event-title">{{ event.summary }}</div>
<div class="event-title">{{ summary }}</div>
<div class="event-details">
<table>
<tr>
<td>Était prévu le</td>
<td>{{ labels.wasScheduledFor }}</td>
<td>
{{ start_date }}<br>
<strong>{{ time_str }}</strong>
{% if start_date != end_date %}<br>jusqu'au {{ end_date }}{% endif %}
{% if start_date != end_date %}<br>{{ labels.until }} {{ end_date }}{% endif %}
</td>
</tr>
{% if event.location %}
<tr>
<td>Lieu</td>
<td>{{ labels.location }}</td>
<td>{{ event.location }}</td>
</tr>
{% endif %}
{% if event.url %}
<tr>
<td>Visio</td>
<td>{{ labels.videoConference }}</td>
<td>{{ event.url }}</td>
</tr>
{% endif %}
<tr>
<td>Organisateur</td>
<td>{{ labels.organizer }}</td>
<td>{{ organizer_display }} &lt;{{ event.organizer_email }}&gt;</td>
</tr>
</table>
</div>
<div class="instructions">
<p>Cet événement a été annulé. Ouvrez le fichier .ics en pièce jointe pour le supprimer de votre calendrier.</p>
<p>{{ instructions }}</p>
</div>
<div class="footer">
<p>Cette notification a été envoyée via {{ app_name }}</p>
<p>{{ footer }}</p>
</div>
</div>
</body>

View File

@@ -1,15 +1,14 @@
{{ organizer_display }} a annulé l'événement : {{ event.summary }}
{{ content.body }}
Détails de l'événement annulé
{{ summary }}
=============================
Titre : {{ event.summary }}
Était prévu le : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
{% if event.location %}Lieu : {{ event.location }}
{% endif %}{% if event.url %}Visio : {{ event.url }}
{% endif %}Organisateur : {{ organizer_display }} <{{ event.organizer_email }}>
{{ labels.wasScheduledFor }} : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
{% if event.location %}{{ labels.location }} : {{ event.location }}
{% endif %}{% if event.url %}{{ labels.videoConference }} : {{ event.url }}
{% endif %}{{ labels.organizer }} : {{ organizer_display }} <{{ event.organizer_email }}>
Cet événement a été annulé. Vous pouvez le supprimer de votre calendrier en ouvrant le fichier .ics en pièce jointe.
{{ instructions }}
---
Cette notification a été envoyée via {{ app_name }}
{{ footer }}

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Réponse à l'événement</title>
<title>{{ content.title }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
@@ -78,48 +78,48 @@
<body>
<div class="container">
<div class="header">
<h1>Réponse reçue</h1>
<h1>{{ content.heading }}</h1>
</div>
<p>{{ attendee_display }} a répondu à votre événement</p>
<p>{{ content.body }}</p>
<div class="event-title">{{ event.summary }}</div>
<div class="event-title">{{ summary }}</div>
<div class="event-details">
<table>
<tr>
<td>Quand</td>
<td>{{ labels.when }}</td>
<td>
{{ start_date }}<br>
<strong>{{ time_str }}</strong>
{% if start_date != end_date %}<br>jusqu'au {{ end_date }}{% endif %}
{% if start_date != end_date %}<br>{{ labels.until }} {{ end_date }}{% endif %}
</td>
</tr>
{% if event.location %}
<tr>
<td>Lieu</td>
<td>{{ labels.location }}</td>
<td>{{ event.location }}</td>
</tr>
{% endif %}
{% if event.url %}
<tr>
<td>Visio</td>
<td>{{ labels.videoConference }}</td>
<td><a href="{{ event.url }}" style="color: #28a745;">{{ event.url }}</a></td>
</tr>
{% endif %}
<tr>
<td>Participant</td>
<td>{{ labels.attendee }}</td>
<td>{{ attendee_display }} &lt;{{ event.attendee_email }}&gt;</td>
</tr>
</table>
</div>
<div class="instructions">
<p>La réponse du participant a été enregistrée. Ouvrez le fichier .ics en pièce jointe pour mettre à jour votre calendrier.</p>
<p>{{ instructions }}</p>
</div>
<div class="footer">
<p>Cette notification a été envoyée via {{ app_name }}</p>
<p>{{ footer }}</p>
</div>
</div>
</body>

View File

@@ -1,14 +1,13 @@
{{ attendee_display }} a répondu à votre événement : {{ event.summary }}
{{ content.body }}
Détails de l'événement
{{ summary }}
======================
Titre : {{ event.summary }}
Quand : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
{% if event.location %}Lieu : {{ event.location }}
{% endif %}{% if event.url %}Visio : {{ event.url }}
{{ labels.when }} : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
{% if event.location %}{{ labels.location }} : {{ event.location }}
{% endif %}{% if event.url %}{{ labels.videoConference }} : {{ event.url }}
{% endif %}
La réponse du participant a été enregistrée dans le fichier .ics en pièce jointe.
{{ instructions }}
---
Cette notification a été envoyée via {{ app_name }}
{{ footer }}

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Événement modifié</title>
<title>{{ content.title }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
@@ -99,39 +99,39 @@
<body>
<div class="container">
<div class="header">
<h1>Événement modifié</h1>
<h1>{{ content.heading }}</h1>
</div>
<span class="update-badge">MODIFIÉ</span>
<span class="update-badge">{{ content.badge }}</span>
<p>{{ organizer_display }} a modifié un événement auquel vous êtes invité(e)</p>
<p>{{ content.body }}</p>
<div class="event-title">{{ event.summary }}</div>
<div class="event-title">{{ summary }}</div>
<div class="event-details">
<table>
<tr>
<td>Quand</td>
<td>{{ labels.when }}</td>
<td>
{{ start_date }}<br>
<strong>{{ time_str }}</strong>
{% if start_date != end_date %}<br>jusqu'au {{ end_date }}{% endif %}
{% if start_date != end_date %}<br>{{ labels.until }} {{ end_date }}{% endif %}
</td>
</tr>
{% if event.location %}
<tr>
<td>Lieu</td>
<td>{{ labels.location }}</td>
<td>{{ event.location }}</td>
</tr>
{% endif %}
{% if event.url %}
<tr>
<td>Visio</td>
<td>{{ labels.videoConference }}</td>
<td><a href="{{ event.url }}" style="color: #e65100;">{{ event.url }}</a></td>
</tr>
{% endif %}
<tr>
<td>Organisateur</td>
<td>{{ labels.organizer }}</td>
<td>{{ organizer_display }} &lt;{{ event.organizer_email }}&gt;</td>
</tr>
</table>
@@ -139,17 +139,24 @@
{% if event.description %}
<div class="description">
<h3>Description</h3>
<h3>{{ labels.description }}</h3>
<p>{{ event.description|linebreaks }}</p>
</div>
{% endif %}
{% if rsvp_accept_url %}
<div style="text-align: center; margin: 20px 0;">
<a href="{{ rsvp_accept_url }}" style="display: inline-block; padding: 10px 24px; margin: 0 6px; background-color: #16a34a; color: white; text-decoration: none; border-radius: 6px; font-weight: bold;">{{ actions.accept }}</a>
<a href="{{ rsvp_tentative_url }}" style="display: inline-block; padding: 10px 24px; margin: 0 6px; background-color: #d97706; color: white; text-decoration: none; border-radius: 6px; font-weight: bold;">{{ actions.maybe }}</a>
<a href="{{ rsvp_decline_url }}" style="display: inline-block; padding: 10px 24px; margin: 0 6px; background-color: #dc2626; color: white; text-decoration: none; border-radius: 6px; font-weight: bold;">{{ actions.decline }}</a>
</div>
{% endif %}
<div class="instructions">
<p>Cet événement a été modifié. Veuillez ouvrir le fichier .ics en pièce jointe pour mettre à jour votre calendrier.</p>
<p>{{ instructions }}</p>
</div>
<div class="footer">
<p>Cette notification a été envoyée via {{ app_name }}</p>
<p>{{ footer }}</p>
</div>
</div>
</body>

View File

@@ -1,20 +1,23 @@
{{ organizer_display }} a modifié l'événement
{{ content.body }}
Détails de l'événement mis à jour
{{ summary }}
=================================
Titre : {{ event.summary }}
Quand : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
{% if event.location %}Lieu : {{ event.location }}
{% endif %}{% if event.url %}Visio : {{ event.url }}
{% endif %}Organisateur : {{ organizer_display }} <{{ event.organizer_email }}>
{{ labels.when }} : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
{% if event.location %}{{ labels.location }} : {{ event.location }}
{% endif %}{% if event.url %}{{ labels.videoConference }} : {{ event.url }}
{% endif %}{{ labels.organizer }} : {{ organizer_display }} <{{ event.organizer_email }}>
{% if event.description %}
Description :
{{ labels.description }} :
{{ event.description }}
{% endif %}
Cet événement a été modifié. Veuillez vérifier les changements et mettre à jour votre calendrier en conséquence.
{% if rsvp_accept_url %}{{ actions.accept }} : {{ rsvp_accept_url }}
{{ actions.maybe }} : {{ rsvp_tentative_url }}
{{ actions.decline }} : {{ rsvp_decline_url }}
{% endif %}{{ instructions }}
---
Cette notification a été envoyée via {{ app_name }}
{{ footer }}

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="{{ lang|default:'fr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
h1 {
color: {{ header_color }};
font-size: 24px;
margin: 0 0 16px;
}
.message {
font-size: 16px;
color: #555;
margin-bottom: 24px;
}
.event-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin: 16px 0;
}
.event-date {
color: #666;
margin-bottom: 24px;
}
.error-container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
</style>
</head>
<body>
<div class="container">
{% if error %}
<div class="icon">&#10060;</div>
<h1 style="color: #dc2626;">{{ error_title }}</h1>
<p class="message">{{ error }}</p>
{% else %}
<div class="icon">{{ status_icon|safe }}</div>
<h1>{{ heading }}</h1>
{% if event_summary %}
<div class="event-title">{{ event_summary }}</div>
{% endif %}
{% if event_date %}
<div class="event-date">{{ event_date }}</div>
{% endif %}
<p class="message">{{ message }}</p>
{% endif %}
</div>
</body>
</html>

View File

@@ -9,11 +9,13 @@ import responses
from rest_framework.status import (
HTTP_200_OK,
HTTP_207_MULTI_STATUS,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
)
from rest_framework.test import APIClient
from core import factories
from core.services.caldav_service import validate_caldav_proxy_path
@pytest.mark.django_db
@@ -305,3 +307,48 @@ class TestCalDAVProxy:
assert response.status_code == HTTP_200_OK
assert "Access-Control-Allow-Methods" in response
assert "PROPFIND" in response["Access-Control-Allow-Methods"]
def test_proxy_rejects_path_traversal(self):
"""Test that proxy rejects paths with directory traversal."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
response = client.generic(
"PROPFIND", "/api/v1.0/caldav/calendars/../../etc/passwd"
)
assert response.status_code == HTTP_400_BAD_REQUEST
def test_proxy_rejects_non_caldav_path(self):
"""Test that proxy rejects paths outside allowed prefixes."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
response = client.generic("PROPFIND", "/api/v1.0/caldav/etc/passwd")
assert response.status_code == HTTP_400_BAD_REQUEST
class TestValidateCaldavProxyPath:
"""Tests for validate_caldav_proxy_path utility."""
def test_empty_path_is_valid(self):
assert validate_caldav_proxy_path("") is True
def test_calendars_path_is_valid(self):
assert validate_caldav_proxy_path("calendars/user@ex.com/uuid/") is True
def test_principals_path_is_valid(self):
assert validate_caldav_proxy_path("principals/user@ex.com/") is True
def test_traversal_is_rejected(self):
assert validate_caldav_proxy_path("calendars/../../etc/passwd") is False
def test_null_byte_is_rejected(self):
assert validate_caldav_proxy_path("calendars/user\x00/") is False
def test_unknown_prefix_is_rejected(self):
assert validate_caldav_proxy_path("etc/passwd") is False
def test_leading_slash_calendars_is_valid(self):
assert validate_caldav_proxy_path("/calendars/user@ex.com/uuid/") is True

View File

@@ -66,3 +66,24 @@ class TestCalDAVClient:
assert caldav_path is not None
assert isinstance(caldav_path, str)
assert "calendars/" in caldav_path
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
def test_create_calendar_with_color_persists(self):
"""Test that creating a calendar with a color saves it in CalDAV."""
user = factories.UserFactory(email="color-test@example.com")
service = CalendarService()
color = "#e74c3c"
# Create a calendar with a specific color
caldav_path = service.create_calendar(
user, name="Red Calendar", color=color
)
# Fetch the calendar info and verify the color was persisted
info = service.caldav.get_calendar_info(user, caldav_path)
assert info is not None
assert info["color"] == color
assert info["name"] == "Red Calendar"

View File

@@ -276,7 +276,7 @@ def _make_sabredav_response( # noqa: PLR0913 # pylint: disable=too-many-argume
class TestICSImportService:
"""Unit tests for ICSImportService with mocked HTTP call to SabreDAV."""
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_single_event(self, mock_post):
"""Importing a single event should succeed."""
mock_post.return_value = _make_sabredav_response(
@@ -299,7 +299,7 @@ class TestICSImportService:
call_kwargs = mock_post.call_args
assert call_kwargs.kwargs["data"] == ICS_SINGLE_EVENT
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_multiple_events(self, mock_post):
"""Importing multiple events should forward all to SabreDAV."""
mock_post.return_value = _make_sabredav_response(
@@ -319,7 +319,7 @@ class TestICSImportService:
# Single HTTP call, not one per event
mock_post.assert_called_once()
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_empty_ics(self, mock_post):
"""Importing an ICS with no events should return zero counts."""
mock_post.return_value = _make_sabredav_response(
@@ -337,7 +337,7 @@ class TestICSImportService:
assert result.skipped_count == 0
assert not result.errors
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_invalid_ics(self, mock_post):
"""Importing invalid ICS data should return an error from SabreDAV."""
mock_post.return_value = _make_sabredav_response(
@@ -354,7 +354,7 @@ class TestICSImportService:
assert result.imported_count == 0
assert len(result.errors) >= 1
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_with_timezone(self, mock_post):
"""Events with timezones should be forwarded to SabreDAV."""
mock_post.return_value = _make_sabredav_response(
@@ -375,7 +375,7 @@ class TestICSImportService:
assert b"VTIMEZONE" in call_kwargs.kwargs["data"]
assert b"Europe/Paris" in call_kwargs.kwargs["data"]
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_partial_failure(self, mock_post):
"""When some events fail, SabreDAV reports partial success."""
mock_post.return_value = _make_sabredav_response(
@@ -404,7 +404,7 @@ class TestICSImportService:
# Only event name is exposed, not raw error details
assert result.errors[0] == "Afternoon review"
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_all_day_event(self, mock_post):
"""All-day events should be forwarded to SabreDAV."""
mock_post.return_value = _make_sabredav_response(
@@ -420,7 +420,7 @@ class TestICSImportService:
assert result.total_events == 1
assert result.imported_count == 1
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_valarm_without_action(self, mock_post):
"""VALARM without ACTION is handled by SabreDAV plugin repair."""
mock_post.return_value = _make_sabredav_response(
@@ -436,7 +436,7 @@ class TestICSImportService:
assert result.total_events == 1
assert result.imported_count == 1
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_recurring_with_exception(self, mock_post):
"""Recurring event + modified occurrence handled by SabreDAV splitter."""
mock_post.return_value = _make_sabredav_response(
@@ -453,7 +453,7 @@ class TestICSImportService:
assert result.total_events == 1
assert result.imported_count == 1
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_event_missing_dtstart(self, mock_post):
"""Events without DTSTART handling is delegated to SabreDAV."""
mock_post.return_value = _make_sabredav_response(
@@ -480,7 +480,7 @@ class TestICSImportService:
assert result.skipped_count == 1
assert result.errors[0] == "Missing start"
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_passes_calendar_path(self, mock_post):
"""The import URL should include the caldav_path."""
mock_post.return_value = _make_sabredav_response(
@@ -498,7 +498,7 @@ class TestICSImportService:
assert caldav_path in url
assert "?import" in url
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_sends_auth_headers(self, mock_post):
"""The import request must include all required auth headers."""
mock_post.return_value = _make_sabredav_response(
@@ -518,7 +518,7 @@ class TestICSImportService:
assert headers["X-Calendars-Import"] == settings.CALDAV_OUTBOUND_API_KEY
assert headers["Content-Type"] == "text/calendar"
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_duplicates_not_treated_as_errors(self, mock_post):
"""Duplicate events should be counted separately, not as errors."""
mock_post.return_value = _make_sabredav_response(
@@ -541,7 +541,7 @@ class TestICSImportService:
assert result.skipped_count == 0
assert not result.errors
@patch("core.services.import_service.requests.post")
@patch("core.services.caldav_service.requests.request")
def test_import_network_failure(self, mock_post):
"""Network failures should return a graceful error."""
mock_post.side_effect = req.ConnectionError("Connection refused")

View File

@@ -0,0 +1,622 @@
"""Tests for RSVP view and token generation."""
import re
from datetime import timedelta
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
from django.core import mail
from django.core.signing import BadSignature, Signer
from django.template.loader import render_to_string
from django.test import RequestFactory, TestCase, override_settings
from django.utils import timezone
import icalendar
import pytest
from core.api.viewsets_rsvp import RSVPView
from core.services.caldav_service import CalDAVHTTPClient
from core.services.calendar_invitation_service import (
CalendarInvitationService,
ICalendarParser,
)
def _make_ics(uid="test-uid-123", summary="Team Meeting", sequence=0, days_from_now=30):
"""Build a sample ICS string with a date relative to now."""
dt = timezone.now() + timedelta(days=days_from_now)
dtstart = dt.strftime("%Y%m%dT%H%M%SZ")
dtend = (dt + timedelta(hours=1)).strftime("%Y%m%dT%H%M%SZ")
return (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
f"UID:{uid}\r\n"
f"DTSTART:{dtstart}\r\n"
f"DTEND:{dtend}\r\n"
f"SUMMARY:{summary}\r\n"
"ORGANIZER;CN=Alice:mailto:alice@example.com\r\n"
"ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:bob@example.com\r\n"
f"SEQUENCE:{sequence}\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR"
)
SAMPLE_ICS = _make_ics()
SAMPLE_ICS_UPDATE = _make_ics(uid="test-uid-456", summary="Updated Meeting", sequence=1)
SAMPLE_ICS_PAST = _make_ics(
uid="test-uid-past", summary="Past Meeting", days_from_now=-30
)
SAMPLE_CALDAV_RESPONSE = """\
<?xml version="1.0" encoding="UTF-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/api/v1.0/caldav/calendars/alice%40example.com/cal-uuid/test-uid-123.ics</d:href>
<d:propstat>
<d:prop>
<d:gethref>/api/v1.0/caldav/calendars/alice%40example.com/cal-uuid/test-uid-123.ics</d:gethref>
<cal:calendar-data>{ics_data}</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"""
def _make_token(
uid="test-uid-123", email="bob@example.com", organizer="alice@example.com"
):
"""Create a valid signed RSVP token."""
signer = Signer(salt="rsvp")
return signer.sign_object(
{
"uid": uid,
"email": email,
"organizer": organizer,
}
)
class TestRSVPTokenGeneration:
"""Tests for RSVP token generation in invitation service."""
def test_token_roundtrip(self):
"""A generated token can be unsigned to recover the payload."""
token = _make_token()
signer = Signer(salt="rsvp")
payload = signer.unsign_object(token)
assert payload["uid"] == "test-uid-123"
assert payload["email"] == "bob@example.com"
assert payload["organizer"] == "alice@example.com"
def test_tampered_token_fails(self):
"""A tampered token raises BadSignature."""
token = _make_token() + "tampered"
signer = Signer(salt="rsvp")
with pytest.raises(BadSignature):
signer.unsign_object(token)
@pytest.mark.django_db
class TestRSVPUrlsInContext:
"""Tests that RSVP URLs are added to template context for REQUEST method."""
def test_request_method_has_rsvp_urls(self):
"""REQUEST method should include RSVP URLs in context."""
event = ICalendarParser.parse(SAMPLE_ICS, "bob@example.com")
service = CalendarInvitationService()
context = service._build_template_context(event, "REQUEST")
assert "rsvp_accept_url" in context
assert "rsvp_tentative_url" in context
assert "rsvp_decline_url" in context
# Check URLs contain proper action params
assert "action=accepted" in context["rsvp_accept_url"]
assert "action=tentative" in context["rsvp_tentative_url"]
assert "action=declined" in context["rsvp_decline_url"]
# Check all URLs contain a token
for key in ("rsvp_accept_url", "rsvp_tentative_url", "rsvp_decline_url"):
parsed = urlparse(context[key])
params = parse_qs(parsed.query)
assert "token" in params
def test_cancel_method_has_no_rsvp_urls(self):
"""CANCEL method should NOT include RSVP URLs."""
event = ICalendarParser.parse(SAMPLE_ICS, "bob@example.com")
service = CalendarInvitationService()
context = service._build_template_context(event, "CANCEL")
assert "rsvp_accept_url" not in context
def test_reply_method_has_no_rsvp_urls(self):
"""REPLY method should NOT include RSVP URLs."""
event = ICalendarParser.parse(SAMPLE_ICS, "bob@example.com")
service = CalendarInvitationService()
context = service._build_template_context(event, "REPLY")
assert "rsvp_accept_url" not in context
@pytest.mark.django_db
class TestRSVPEmailTemplateRendering:
"""Tests that RSVP buttons appear in email templates."""
def _build_context(self, ics_data, method="REQUEST"):
event = ICalendarParser.parse(ics_data, "bob@example.com")
service = CalendarInvitationService()
return service._build_template_context(event, method)
def test_invitation_html_has_rsvp_buttons(self):
context = self._build_context(SAMPLE_ICS)
html = render_to_string("emails/calendar_invitation.html", context)
assert "Accepter" in html
assert "Peut-être" in html
assert "Refuser" in html
def test_invitation_txt_has_rsvp_links(self):
context = self._build_context(SAMPLE_ICS)
txt = render_to_string("emails/calendar_invitation.txt", context)
assert "Accepter" in txt
assert "Peut-être" in txt
assert "Refuser" in txt
def test_update_html_has_rsvp_buttons(self):
context = self._build_context(SAMPLE_ICS_UPDATE)
html = render_to_string("emails/calendar_invitation_update.html", context)
assert "Accepter" in html
assert "Peut-être" in html
assert "Refuser" in html
def test_update_txt_has_rsvp_links(self):
context = self._build_context(SAMPLE_ICS_UPDATE)
txt = render_to_string("emails/calendar_invitation_update.txt", context)
assert "Accepter" in txt
assert "Peut-être" in txt
assert "Refuser" in txt
def test_cancel_html_has_no_rsvp_buttons(self):
context = self._build_context(SAMPLE_ICS, method="CANCEL")
html = render_to_string("emails/calendar_invitation_cancel.html", context)
assert "rsvp" not in html.lower() or "Accepter" not in html
def test_invitation_html_no_rsvp_for_cancel(self):
"""Cancel templates don't have RSVP buttons."""
context = self._build_context(SAMPLE_ICS, method="CANCEL")
html = render_to_string("emails/calendar_invitation_cancel.html", context)
assert "Accepter" not in html
class TestUpdateAttendeePartstat:
"""Tests for the _update_attendee_partstat function."""
def test_update_existing_partstat(self):
result = CalDAVHTTPClient.update_attendee_partstat(
SAMPLE_ICS, "bob@example.com", "ACCEPTED"
)
assert result is not None
assert "PARTSTAT=ACCEPTED" in result
assert "PARTSTAT=NEEDS-ACTION" not in result
def test_update_to_declined(self):
result = CalDAVHTTPClient.update_attendee_partstat(
SAMPLE_ICS, "bob@example.com", "DECLINED"
)
assert result is not None
assert "PARTSTAT=DECLINED" in result
def test_update_to_tentative(self):
result = CalDAVHTTPClient.update_attendee_partstat(
SAMPLE_ICS, "bob@example.com", "TENTATIVE"
)
assert result is not None
assert "PARTSTAT=TENTATIVE" in result
def test_unknown_attendee_returns_none(self):
result = CalDAVHTTPClient.update_attendee_partstat(
SAMPLE_ICS, "unknown@example.com", "ACCEPTED"
)
assert result is None
def test_preserves_other_attendee_properties(self):
result = CalDAVHTTPClient.update_attendee_partstat(
SAMPLE_ICS, "bob@example.com", "ACCEPTED"
)
assert result is not None
assert "CN=Bob" in result
assert "mailto:bob@example.com" in result
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
APP_URL="http://localhost:8921",
)
class TestRSVPView(TestCase):
"""Tests for the RSVPView."""
def setUp(self):
self.factory = RequestFactory()
self.view = RSVPView.as_view()
def test_invalid_action_returns_400(self):
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "invalid"})
response = self.view(request)
assert response.status_code == 400
def test_missing_action_returns_400(self):
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token})
response = self.view(request)
assert response.status_code == 400
def test_invalid_token_returns_400(self):
request = self.factory.get(
"/rsvp/", {"token": "bad-token", "action": "accepted"}
)
response = self.view(request)
assert response.status_code == 400
def test_missing_token_returns_400(self):
request = self.factory.get("/rsvp/", {"action": "accepted"})
response = self.view(request)
assert response.status_code == 400
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_accept_flow(self, mock_find, mock_put):
"""Full accept flow: find event, update partstat, put back."""
mock_find.return_value = (
SAMPLE_ICS,
"/api/v1.0/caldav/calendars/alice%40example.com/cal/event.ics",
)
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
assert response.status_code == 200
assert "accepted the invitation" in response.content.decode()
# Verify CalDAV calls
mock_find.assert_called_once_with("alice@example.com", "test-uid-123")
mock_put.assert_called_once()
# Check the updated data contains ACCEPTED
put_args = mock_put.call_args
assert "PARTSTAT=ACCEPTED" in put_args[0][2]
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_decline_flow(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "declined"})
response = self.view(request)
assert response.status_code == 200
assert "declined the invitation" in response.content.decode()
put_args = mock_put.call_args
assert "PARTSTAT=DECLINED" in put_args[0][2]
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_tentative_flow(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_put.return_value = True
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "tentative"})
response = self.view(request)
assert response.status_code == 200
content = response.content.decode()
assert "maybe" in content.lower()
put_args = mock_put.call_args
assert "PARTSTAT=TENTATIVE" in put_args[0][2]
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_event_not_found_returns_400(self, mock_find):
mock_find.return_value = (None, None)
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
assert response.status_code == 400
assert "not found" in response.content.decode().lower()
@patch.object(CalDAVHTTPClient, "put_event")
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_put_failure_returns_400(self, mock_find, mock_put):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_put.return_value = False
token = _make_token()
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
assert response.status_code == 400
assert "error occurred" in response.content.decode().lower()
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_attendee_not_in_event_returns_400(self, mock_find):
"""If the attendee email is not in the event, return error."""
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
# Token with an email that's not in the event
token = _make_token(email="stranger@example.com")
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
assert response.status_code == 400
assert "not listed" in response.content.decode().lower()
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_past_event_returns_400(self, mock_find):
"""Cannot RSVP to an event that has already ended."""
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics")
token = _make_token(uid="test-uid-past")
request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"})
response = self.view(request)
assert response.status_code == 400
assert "already passed" in response.content.decode().lower()
def _make_ics_with_method(method="REQUEST"):
"""Build a sample ICS string that includes a METHOD property."""
return (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
f"METHOD:{method}\r\n"
"PRODID:-//Test//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:itip-test\r\n"
"DTSTART:20260301T100000Z\r\n"
"DTEND:20260301T110000Z\r\n"
"SUMMARY:iTIP test\r\n"
"ORGANIZER:mailto:alice@example.com\r\n"
"ATTENDEE:mailto:bob@example.com\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR"
)
@pytest.mark.django_db
class TestItipSetting:
"""Tests for CALENDAR_ITIP_ENABLED setting on ICS attachments."""
def _prepare(self, ics_data, method="REQUEST"):
service = CalendarInvitationService()
return service._prepare_ics_attachment(ics_data, method)
@override_settings(CALENDAR_ITIP_ENABLED=False)
def test_disabled_strips_existing_method(self):
result = self._prepare(_make_ics_with_method("REQUEST"))
cal = icalendar.Calendar.from_ical(result)
assert "METHOD" not in cal
@override_settings(CALENDAR_ITIP_ENABLED=False)
def test_disabled_does_not_add_method(self):
result = self._prepare(SAMPLE_ICS)
cal = icalendar.Calendar.from_ical(result)
assert "METHOD" not in cal
@override_settings(CALENDAR_ITIP_ENABLED=True)
def test_enabled_adds_method(self):
result = self._prepare(SAMPLE_ICS, method="REQUEST")
cal = icalendar.Calendar.from_ical(result)
assert str(cal["METHOD"]) == "REQUEST"
@override_settings(CALENDAR_ITIP_ENABLED=True)
def test_enabled_updates_existing_method(self):
result = self._prepare(_make_ics_with_method("CANCEL"), method="REQUEST")
cal = icalendar.Calendar.from_ical(result)
assert str(cal["METHOD"]) == "REQUEST"
@override_settings(
CALDAV_URL="http://caldav:80",
CALDAV_OUTBOUND_API_KEY="test-api-key",
CALDAV_INBOUND_API_KEY="test-inbound-key",
APP_URL="http://localhost:8921",
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
)
class TestRSVPEndToEndFlow(TestCase):
"""
Integration test: scheduling callback sends email → extract RSVP links
→ follow link → verify event is updated.
This tests the full flow from CalDAV scheduling callback to RSVP response,
using Django's in-memory email backend to intercept sent emails.
"""
def setUp(self):
self.factory = RequestFactory()
self.rsvp_view = RSVPView.as_view()
def test_email_to_rsvp_accept_flow(self):
"""
1. CalDAV scheduling callback sends an invitation email
2. Extract RSVP accept link from the email HTML
3. Follow the RSVP link
4. Verify the event PARTSTAT is updated to ACCEPTED
"""
# Step 1: Send invitation via the CalendarInvitationService
service = CalendarInvitationService()
success = service.send_invitation(
sender_email="alice@example.com",
recipient_email="bob@example.com",
method="REQUEST",
icalendar_data=SAMPLE_ICS,
)
assert success is True
assert len(mail.outbox) == 1
sent_email = mail.outbox[0]
assert "bob@example.com" in sent_email.to
# Step 2: Extract RSVP accept link from email HTML
html_body = None
for alternative in sent_email.alternatives:
if alternative[1] == "text/html":
html_body = alternative[0]
break
assert html_body is not None, "Email should have an HTML body"
# Find the accept link (green button with "Accepter")
accept_match = re.search(r'<a\s+href="([^"]*action=accepted[^"]*)"', html_body)
assert accept_match is not None, "Email HTML should contain an RSVP accept link"
accept_url = accept_match.group(1)
# Unescape HTML entities
accept_url = accept_url.replace("&amp;", "&")
# Step 3: Parse the URL and extract token + action
parsed = urlparse(accept_url)
params = parse_qs(parsed.query)
assert "token" in params
assert params["action"] == ["accepted"]
# Step 4: Follow the RSVP link (mock CalDAV interactions)
with (
patch.object(CalDAVHTTPClient, "find_event_by_uid") as mock_find,
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
):
mock_find.return_value = (
SAMPLE_ICS,
"/api/v1.0/caldav/calendars/alice%40example.com/cal/event.ics",
)
mock_put.return_value = True
request = self.factory.get(
"/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.rsvp_view(request)
# Step 5: Verify success
assert response.status_code == 200
content = response.content.decode()
assert "accepted the invitation" in content
# Verify CalDAV was called with the right data
mock_find.assert_called_once_with("alice@example.com", "test-uid-123")
mock_put.assert_called_once()
put_data = mock_put.call_args[0][2]
assert "PARTSTAT=ACCEPTED" in put_data
def test_email_to_rsvp_decline_flow(self):
"""Same flow but for declining an invitation."""
service = CalendarInvitationService()
service.send_invitation(
sender_email="alice@example.com",
recipient_email="bob@example.com",
method="REQUEST",
icalendar_data=SAMPLE_ICS,
)
assert len(mail.outbox) == 1
html_body = next(
alt[0] for alt in mail.outbox[0].alternatives if alt[1] == "text/html"
)
decline_match = re.search(r'<a\s+href="([^"]*action=declined[^"]*)"', html_body)
assert decline_match is not None
decline_url = decline_match.group(1).replace("&amp;", "&")
parsed = urlparse(decline_url)
params = parse_qs(parsed.query)
with (
patch.object(CalDAVHTTPClient, "find_event_by_uid") as mock_find,
patch.object(CalDAVHTTPClient, "put_event") as mock_put,
):
mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics")
mock_put.return_value = True
request = self.factory.get(
"/rsvp/",
{"token": params["token"][0], "action": "declined"},
)
response = self.rsvp_view(request)
assert response.status_code == 200
assert "declined the invitation" in response.content.decode()
assert "PARTSTAT=DECLINED" in mock_put.call_args[0][2]
def test_email_contains_all_three_rsvp_links(self):
"""Verify the email contains accept, tentative, and decline links."""
service = CalendarInvitationService()
service.send_invitation(
sender_email="alice@example.com",
recipient_email="bob@example.com",
method="REQUEST",
icalendar_data=SAMPLE_ICS,
)
html_body = next(
alt[0] for alt in mail.outbox[0].alternatives if alt[1] == "text/html"
)
for action in ("accepted", "tentative", "declined"):
match = re.search(rf'<a\s+href="([^"]*action={action}[^"]*)"', html_body)
assert match is not None, (
f"Email should contain an RSVP link for action={action}"
)
def test_cancel_email_has_no_rsvp_links(self):
"""Cancel emails should NOT contain any RSVP links."""
service = CalendarInvitationService()
service.send_invitation(
sender_email="alice@example.com",
recipient_email="bob@example.com",
method="CANCEL",
icalendar_data=SAMPLE_ICS,
)
assert len(mail.outbox) == 1
html_body = next(
alt[0] for alt in mail.outbox[0].alternatives if alt[1] == "text/html"
)
assert "action=accepted" not in html_body
assert "action=declined" not in html_body
@patch.object(CalDAVHTTPClient, "find_event_by_uid")
def test_rsvp_link_for_past_event_fails(self, mock_find):
"""RSVP link for a past event should return an error."""
service = CalendarInvitationService()
service.send_invitation(
sender_email="alice@example.com",
recipient_email="bob@example.com",
method="REQUEST",
icalendar_data=SAMPLE_ICS,
)
html_body = next(
alt[0] for alt in mail.outbox[0].alternatives if alt[1] == "text/html"
)
accept_match = re.search(r'<a\s+href="([^"]*action=accepted[^"]*)"', html_body)
accept_url = accept_match.group(1).replace("&amp;", "&")
parsed = urlparse(accept_url)
params = parse_qs(parsed.query)
# The event is in the past
mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics")
request = self.factory.get(
"/rsvp/",
{"token": params["token"][0], "action": "accepted"},
)
response = self.rsvp_view(request)
assert response.status_code == 400
assert "already passed" in response.content.decode().lower()

View File

@@ -0,0 +1,93 @@
"""Tests for TranslationService."""
from datetime import datetime
from core.services.translation_service import TranslationService
class TestTranslationServiceLookup:
"""Tests for key lookup and interpolation."""
def test_lookup_french_key(self):
value = TranslationService.t("email.subject.invitation", "fr", summary="Test")
assert value == "Invitation : Test"
def test_lookup_english_key(self):
value = TranslationService.t("email.subject.invitation", "en", summary="Test")
assert value == "Invitation: Test"
def test_lookup_dutch_key(self):
value = TranslationService.t("email.subject.cancel", "nl", summary="Test")
assert value == "Geannuleerd: Test"
def test_fallback_to_english(self):
"""If key is missing in requested lang, falls back to English."""
value = TranslationService.t("email.noTitle", "en")
assert value == "(No title)"
def test_fallback_to_key_if_missing(self):
"""If key is missing everywhere, returns the key itself."""
value = TranslationService.t("nonexistent.key", "fr")
assert value == "nonexistent.key"
def test_interpolation_multiple_vars(self):
value = TranslationService.t("email.invitation.body", "en", organizer="Alice")
assert "Alice" in value
def test_rsvp_keys(self):
assert "accepted" in TranslationService.t("rsvp.accepted", "en").lower()
assert "accepté" in TranslationService.t("rsvp.accepted", "fr").lower()
def test_error_keys(self):
value = TranslationService.t("rsvp.error.eventPast", "fr")
assert "passé" in value
class TestNormalizeLang:
"""Tests for language normalization."""
def test_normalize_fr_fr(self):
assert TranslationService.normalize_lang("fr-fr") == "fr"
def test_normalize_en_us(self):
assert TranslationService.normalize_lang("en-us") == "en"
def test_normalize_nl_nl(self):
assert TranslationService.normalize_lang("nl-nl") == "nl"
def test_normalize_simple(self):
assert TranslationService.normalize_lang("fr") == "fr"
def test_normalize_unknown_falls_back_to_fr(self):
assert TranslationService.normalize_lang("de") == "fr"
def test_normalize_empty(self):
assert TranslationService.normalize_lang("") == "fr"
class TestFormatDate:
"""Tests for date formatting."""
def test_format_date_french(self):
dt = datetime(2026, 1, 23, 10, 0) # Friday
result = TranslationService.format_date(dt, "fr")
assert "vendredi" in result
assert "23" in result
assert "janvier" in result
assert "2026" in result
def test_format_date_english(self):
dt = datetime(2026, 1, 23, 10, 0) # Friday
result = TranslationService.format_date(dt, "en")
assert "Friday" in result
assert "23" in result
assert "January" in result
assert "2026" in result
def test_format_date_dutch(self):
dt = datetime(2026, 1, 23, 10, 0) # Friday
result = TranslationService.format_date(dt, "nl")
assert "vrijdag" in result
assert "23" in result
assert "januari" in result
assert "2026" in result

View File

@@ -9,6 +9,7 @@ from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.api.viewsets_caldav import CalDAVProxyView, CalDAVSchedulingCallbackView
from core.api.viewsets_ical import ICalExportView
from core.api.viewsets_rsvp import RSVPView
from core.external_api import viewsets as external_api_viewsets
# - Main endpoints
@@ -54,6 +55,9 @@ urlpatterns = [
ICalExportView.as_view(),
name="ical-export",
),
# RSVP endpoint (no authentication required)
# Signed token in query string acts as authentication
path("rsvp/", RSVPView.as_view(), name="rsvp"),
]

View File

@@ -8,6 +8,7 @@ import {
useCallback,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { CalDavService } from "../services/dav/CalDavService";
import { EventCalendarAdapter } from "../services/dav/EventCalendarAdapter";
import { caldavServerUrl, headers, fetchOptions } from "../utils/DavClient";
@@ -17,6 +18,10 @@ import type {
} from "../services/dav/types/caldav-service";
import type { CalendarApi } from "../components/scheduler/types";
import { createCalendarApi } from "../api";
import {
addToast,
ToasterItem,
} from "@/features/ui/components/toaster/Toaster";
const HIDDEN_CALENDARS_KEY = "calendar-hidden-urls";
@@ -97,10 +102,13 @@ interface CalendarContextProviderProps {
export const CalendarContextProvider = ({
children,
}: CalendarContextProviderProps) => {
const { t } = useTranslation();
const calendarRef = useRef<CalendarApi | null>(null);
const caldavService = useMemo(() => new CalDavService(), []);
const adapter = useMemo(() => new EventCalendarAdapter(), []);
const [davCalendars, setDavCalendars] = useState<CalDavCalendar[]>([]);
const davCalendarsRef = useRef<CalDavCalendar[]>([]);
davCalendarsRef.current = davCalendars;
const [visibleCalendarUrls, setVisibleCalendarUrls] = useState<Set<string>>(
new Set(),
);
@@ -139,17 +147,27 @@ export const CalendarContextProvider = ({
);
} else {
console.error("Error fetching calendars:", result.error);
addToast(
<ToasterItem type="error" closeButton>
{t("calendar.error.fetchCalendars")}
</ToasterItem>,
);
setDavCalendars([]);
setVisibleCalendarUrls(new Set());
}
} catch (error) {
console.error("Error loading calendars:", error);
addToast(
<ToasterItem type="error" closeButton>
{t("calendar.error.fetchCalendars")}
</ToasterItem>,
);
setDavCalendars([]);
setVisibleCalendarUrls(new Set());
} finally {
setIsLoading(false);
}
}, [caldavService]);
}, [caldavService, t]);
const toggleCalendarVisibility = useCallback((calendarUrl: string) => {
setVisibleCalendarUrls((prev) => {
@@ -160,12 +178,13 @@ export const CalendarContextProvider = ({
newVisible.add(calendarUrl);
}
// Persist: store the hidden set (all known URLs minus visible)
const allUrls = davCalendars.map((cal) => cal.url);
// Use ref to avoid stale closure over davCalendars
const allUrls = davCalendarsRef.current.map((cal) => cal.url);
const newHidden = new Set(allUrls.filter((url) => !newVisible.has(url)));
saveHiddenUrls(newHidden);
return newVisible;
});
}, [davCalendars]);
}, []);
const createCalendar = useCallback(
async (
@@ -319,11 +338,21 @@ export const CalendarContextProvider = ({
setIsLoading(false);
} else if (isMounted) {
console.error("Failed to connect to CalDAV:", result.error);
addToast(
<ToasterItem type="error" closeButton>
{t("calendar.error.connection")}
</ToasterItem>,
);
setIsLoading(false);
}
} catch (error) {
if (isMounted) {
console.error("Error connecting to CalDAV:", error);
addToast(
<ToasterItem type="error" closeButton>
{t("calendar.error.connection")}
</ToasterItem>,
);
setIsLoading(false);
}
}

View File

@@ -13,6 +13,7 @@ import {
convertIcsTimezone,
generateIcsCalendar,
type IcsCalendar,
type IcsDateObject,
type IcsEvent,
} from 'ts-ics'
import {
@@ -335,7 +336,10 @@ export class CalDavService {
}
/**
* Add EXDATE to a recurring event to exclude specific occurrences
* Add EXDATE to a recurring event to exclude specific occurrences.
*
* Uses ts-ics to parse and regenerate the ICS, ensuring correct
* EXDATE formatting (DATE vs DATE-TIME, timezone handling).
*/
async addExdateToEvent(
eventUrl: string,
@@ -357,111 +361,34 @@ export class CalDavService {
throw new Error(`Failed to fetch event: ${fetchResponse.status}`)
}
let icsText = await fetchResponse.text()
const icsText = await fetchResponse.text()
// Extract DTSTART format from the VEVENT block (not VTIMEZONE)
// Match DTSTART that comes after BEGIN:VEVENT
const veventMatch = icsText.match(/BEGIN:VEVENT[\s\S]*?DTSTART(;[^\r\n]*)?:([^\r\n]+)/)
let exdateLine = ''
if (veventMatch) {
const dtstartParams = veventMatch[1] || ''
const dtstartValue = veventMatch[2]
// Check if it's a DATE-only value (YYYYMMDD format, 8 chars)
const isDateOnly = dtstartValue.trim().length === 8
// Format the EXDATE to match DTSTART format
const pad = (n: number) => n.toString().padStart(2, '0')
if (isDateOnly) {
// DATE format: YYYYMMDD
const year = exdateToAdd.getFullYear()
const month = pad(exdateToAdd.getMonth() + 1)
const day = pad(exdateToAdd.getDate())
const formattedDate = `${year}${month}${day}`
exdateLine = `EXDATE${dtstartParams}:${formattedDate}`
} else {
// DATE-TIME format
const pad = (n: number) => n.toString().padStart(2, '0')
// Extract time from DTSTART value (format: YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ)
const timeMatch = dtstartValue.match(/T(\d{2})(\d{2})(\d{2})/)
const originalHours = timeMatch ? timeMatch[1] : '00'
const originalMinutes = timeMatch ? timeMatch[2] : '00'
const originalSeconds = timeMatch ? timeMatch[3] : '00'
// If DTSTART has TZID, use local time in that timezone
// Otherwise use UTC with Z suffix
if (dtstartParams.includes('TZID')) {
// Use the DATE from exdateToAdd but TIME from original DTSTART
const year = exdateToAdd.getFullYear()
const month = pad(exdateToAdd.getMonth() + 1)
const day = pad(exdateToAdd.getDate())
const formattedDate = `${year}${month}${day}T${originalHours}${originalMinutes}${originalSeconds}`
exdateLine = `EXDATE${dtstartParams}:${formattedDate}`
} else {
// Use UTC time with Z suffix
const year = exdateToAdd.getUTCFullYear()
const month = pad(exdateToAdd.getUTCMonth() + 1)
const day = pad(exdateToAdd.getUTCDate())
const formattedDate = `${year}${month}${day}T${originalHours}${originalMinutes}${originalSeconds}Z`
exdateLine = `EXDATE${dtstartParams}:${formattedDate}`
}
}
} else {
// Fallback if DTSTART not found - use UTC DATE-TIME format
const pad = (n: number) => n.toString().padStart(2, '0')
const year = exdateToAdd.getUTCFullYear()
const month = pad(exdateToAdd.getUTCMonth() + 1)
const day = pad(exdateToAdd.getUTCDate())
const hours = pad(exdateToAdd.getUTCHours())
const minutes = pad(exdateToAdd.getUTCMinutes())
const seconds = pad(exdateToAdd.getUTCSeconds())
const formattedDate = `${year}${month}${day}T${hours}${minutes}${seconds}Z`
exdateLine = `EXDATE:${formattedDate}`
// Parse ICS into structured object
const calendar = convertIcsCalendar(undefined, icsText)
const event = calendar.events?.[0]
if (!event) {
throw new Error('No event found in ICS data')
}
// Find the RRULE line in the VEVENT block and add EXDATE after it
const lines = icsText.split('\n')
const newLines: string[] = []
let exdateAdded = false
let inVEvent = false
// Extract just the date value from our exdateLine for appending
const exdateValueMatch = exdateLine.match(/:([^\r\n]+)$/)
const exdateValue = exdateValueMatch ? exdateValueMatch[1] : ''
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
// Track if we're inside a VEVENT block
if (line === 'BEGIN:VEVENT') {
inVEvent = true
newLines.push(lines[i])
} else if (line === 'END:VEVENT') {
inVEvent = false
newLines.push(lines[i])
}
// If EXDATE already exists in VEVENT, append to it
else if (inVEvent && !exdateAdded && (line.startsWith('EXDATE:') || line.startsWith('EXDATE;'))) {
newLines.push(`${lines[i]},${exdateValue}`)
exdateAdded = true
}
// Only add EXDATE after RRULE if we're inside VEVENT and no EXDATE exists yet
else if (inVEvent && !exdateAdded && line.startsWith('RRULE:')) {
newLines.push(lines[i])
newLines.push(exdateLine)
exdateAdded = true
}
else {
newLines.push(lines[i])
}
// Build EXDATE entry matching DTSTART format (DATE vs DATE-TIME, timezone)
const newExdate: IcsDateObject = {
date: exdateToAdd,
type: event.start.type,
local: event.start.local ? {
date: exdateToAdd,
timezone: event.start.local.timezone,
tzoffset: event.start.local.tzoffset,
} : undefined,
}
icsText = newLines.join('\n')
// Append to existing exception dates
event.exceptionDates = [...(event.exceptionDates ?? []), newExdate]
// Update the event with modified ICS
// Regenerate ICS with the updated event
this.validateTimezones(calendar)
const updatedIcsText = generateIcsCalendar(calendar)
// PUT the updated event back
const updateResponse = await fetch(eventUrl, {
method: 'PUT',
headers: {
@@ -469,7 +396,7 @@ export class CalDavService {
...(etag ? { 'If-Match': etag } : {}),
...this._account?.headers,
},
body: icsText,
body: updatedIcsText,
...this._account?.fetchOptions,
})

View File

@@ -5,7 +5,9 @@
* Used by CalendarContext to initialize CalDavService.
*/
export const caldavServerUrl = `${process.env.NEXT_PUBLIC_API_ORIGIN}/api/v1.0/caldav/`;
import { getOrigin } from "@/features/api/utils";
export const caldavServerUrl = `${getOrigin()}/api/v1.0/caldav/`;
export const headers = {
"Content-Type": "application/xml",

View File

@@ -91,6 +91,10 @@
"seconds_ago": "few seconds ago"
},
"calendar": {
"error": {
"connection": "Failed to connect to the calendar server. Please try again later.",
"fetchCalendars": "Failed to load calendars. Please try again later."
},
"views": {
"day": "Day",
"week": "Week",
@@ -319,6 +323,108 @@
"organizer": "Organizer",
"viewProfile": "View profile",
"cannotRemoveOrganizer": "Cannot remove organizer"
},
"weekdaysFull": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
}
},
"email": {
"subject": {
"invitation": "Invitation: {{summary}}",
"update": "Updated invitation: {{summary}}",
"cancel": "Cancelled: {{summary}}",
"reply": "Reply: {{summary}}"
},
"noTitle": "(No title)",
"allDay": "All day",
"invitation": {
"title": "Event invitation",
"heading": "Event invitation",
"body": "{{organizer}} invites you to an event"
},
"update": {
"title": "Event updated",
"heading": "Event updated",
"badge": "UPDATED",
"body": "{{organizer}} has updated an event you are invited to"
},
"cancel": {
"title": "Event cancelled",
"heading": "Event cancelled",
"badge": "CANCELLED",
"body": "{{organizer}} has cancelled the following event"
},
"reply": {
"title": "Event reply",
"heading": "Reply received",
"body": "{{attendee}} has replied to your event"
},
"labels": {
"when": "When",
"until": "until",
"location": "Location",
"videoConference": "Video conference",
"organizer": "Organizer",
"attendee": "Attendee",
"description": "Description",
"wasScheduledFor": "Was scheduled for"
},
"actions": {
"accept": "Accept",
"maybe": "Maybe",
"decline": "Decline"
},
"instructions": {
"invitation": "You can also open the attached .ics file with your calendar application.",
"update": "This event has been updated. You can also open the attached .ics file to update your calendar.",
"cancel": "This event has been cancelled. Open the attached .ics file to remove it from your calendar.",
"reply": "The attendee's reply has been recorded. Open the attached .ics file to update your calendar."
},
"footer": {
"invitation": "This invitation was sent via {{appName}}",
"notification": "This notification was sent via {{appName}}"
},
"replyTo": {
"body": "{{attendee}} has replied to your event: {{summary}}",
"details": "Event details",
"instruction": "The attendee's reply has been recorded in the attached .ics file."
},
"cancelDetails": {
"body": "{{organizer}} has cancelled the event: {{summary}}",
"details": "Cancelled event details",
"instruction": "This event has been cancelled. You can remove it from your calendar by opening the attached .ics file."
},
"updateDetails": {
"body": "{{organizer}} has updated the event",
"details": "Updated event details"
},
"invitationDetails": {
"body": "{{organizer}} invites you to an event",
"details": "Event details",
"respond": "Respond to this invitation:"
}
},
"rsvp": {
"accepted": "You have accepted the invitation",
"tentative": "You have replied \"maybe\" to the invitation",
"declined": "You have declined the invitation",
"responseSent": "Your response has been sent to the organizer.",
"error": {
"title": "Error",
"invalidLink": "Invalid link",
"invalidAction": "Invalid action.",
"invalidToken": "This link is invalid or has expired.",
"invalidPayload": "This link is invalid.",
"eventNotFound": "The event was not found. It may have been deleted.",
"eventPast": "This event has already passed.",
"notAttendee": "You are not listed as an attendee of this event.",
"updateFailed": "An error occurred while updating. Please try again."
}
}
}
@@ -718,6 +824,10 @@
"seconds_ago": "il y a quelques secondes"
},
"calendar": {
"error": {
"connection": "Impossible de se connecter au serveur de calendrier. Veuillez réessayer plus tard.",
"fetchCalendars": "Impossible de charger les calendriers. Veuillez réessayer plus tard."
},
"views": {
"day": "Jour",
"week": "Semaine",
@@ -946,6 +1056,108 @@
"organizer": "Organisateur",
"viewProfile": "Voir le profil",
"cannotRemoveOrganizer": "Impossible de retirer l'organisateur"
},
"weekdaysFull": {
"monday": "lundi",
"tuesday": "mardi",
"wednesday": "mercredi",
"thursday": "jeudi",
"friday": "vendredi",
"saturday": "samedi",
"sunday": "dimanche"
}
},
"email": {
"subject": {
"invitation": "Invitation : {{summary}}",
"update": "Invitation modifiée : {{summary}}",
"cancel": "Annulé : {{summary}}",
"reply": "Réponse : {{summary}}"
},
"noTitle": "(Sans titre)",
"allDay": "Toute la journée",
"invitation": {
"title": "Invitation à un événement",
"heading": "Invitation à un événement",
"body": "{{organizer}} vous invite à un événement"
},
"update": {
"title": "Événement modifié",
"heading": "Événement modifié",
"badge": "MODIFIÉ",
"body": "{{organizer}} a modifié un événement auquel vous êtes invité(e)"
},
"cancel": {
"title": "Événement annulé",
"heading": "Événement annulé",
"badge": "ANNULÉ",
"body": "{{organizer}} a annulé l'événement suivant"
},
"reply": {
"title": "Réponse à l'événement",
"heading": "Réponse reçue",
"body": "{{attendee}} a répondu à votre événement"
},
"labels": {
"when": "Quand",
"until": "jusqu'au",
"location": "Lieu",
"videoConference": "Visio",
"organizer": "Organisateur",
"attendee": "Participant",
"description": "Description",
"wasScheduledFor": "Était prévu le"
},
"actions": {
"accept": "Accepter",
"maybe": "Peut-être",
"decline": "Refuser"
},
"instructions": {
"invitation": "Vous pouvez aussi ouvrir le fichier .ics en pièce jointe avec votre application de calendrier.",
"update": "Cet événement a été modifié. Vous pouvez aussi ouvrir le fichier .ics en pièce jointe pour mettre à jour votre calendrier.",
"cancel": "Cet événement a été annulé. Ouvrez le fichier .ics en pièce jointe pour le supprimer de votre calendrier.",
"reply": "La réponse du participant a été enregistrée. Ouvrez le fichier .ics en pièce jointe pour mettre à jour votre calendrier."
},
"footer": {
"invitation": "Cette invitation a été envoyée via {{appName}}",
"notification": "Cette notification a été envoyée via {{appName}}"
},
"replyTo": {
"body": "{{attendee}} a répondu à votre événement : {{summary}}",
"details": "Détails de l'événement",
"instruction": "La réponse du participant a été enregistrée dans le fichier .ics en pièce jointe."
},
"cancelDetails": {
"body": "{{organizer}} a annulé l'événement : {{summary}}",
"details": "Détails de l'événement annulé",
"instruction": "Cet événement a été annulé. Vous pouvez le supprimer de votre calendrier en ouvrant le fichier .ics en pièce jointe."
},
"updateDetails": {
"body": "{{organizer}} a modifié l'événement",
"details": "Détails de l'événement mis à jour"
},
"invitationDetails": {
"body": "{{organizer}} vous invite à un événement",
"details": "Détails de l'événement",
"respond": "Répondre à cette invitation :"
}
},
"rsvp": {
"accepted": "Vous avez accepté l'invitation",
"tentative": "Vous avez répondu « peut-être » à l'invitation",
"declined": "Vous avez décliné l'invitation",
"responseSent": "Votre réponse a été envoyée à l'organisateur.",
"error": {
"title": "Erreur",
"invalidLink": "Lien invalide",
"invalidAction": "Action invalide.",
"invalidToken": "Ce lien est invalide ou a expiré.",
"invalidPayload": "Ce lien est invalide.",
"eventNotFound": "L'événement n'a pas été trouvé. Il a peut-être été supprimé.",
"eventPast": "Cet événement est déjà passé.",
"notAttendee": "Vous ne figurez pas parmi les participants de cet événement.",
"updateFailed": "Une erreur est survenue lors de la mise à jour. Veuillez réessayer."
}
}
}
@@ -1092,6 +1304,10 @@
"seconds_ago": "een paar seconden geleden"
},
"calendar": {
"error": {
"connection": "Kan geen verbinding maken met de kalenderserver. Probeer het later opnieuw.",
"fetchCalendars": "Kan kalenders niet laden. Probeer het later opnieuw."
},
"views": {
"day": "Dag",
"week": "Week",
@@ -1320,6 +1536,108 @@
"organizer": "Organisator",
"viewProfile": "Profiel bekijken",
"cannotRemoveOrganizer": "Kan organisator niet verwijderen"
},
"weekdaysFull": {
"monday": "maandag",
"tuesday": "dinsdag",
"wednesday": "woensdag",
"thursday": "donderdag",
"friday": "vrijdag",
"saturday": "zaterdag",
"sunday": "zondag"
}
},
"email": {
"subject": {
"invitation": "Uitnodiging: {{summary}}",
"update": "Bijgewerkte uitnodiging: {{summary}}",
"cancel": "Geannuleerd: {{summary}}",
"reply": "Antwoord: {{summary}}"
},
"noTitle": "(Geen titel)",
"allDay": "Hele dag",
"invitation": {
"title": "Uitnodiging voor evenement",
"heading": "Uitnodiging voor evenement",
"body": "{{organizer}} nodigt u uit voor een evenement"
},
"update": {
"title": "Evenement bijgewerkt",
"heading": "Evenement bijgewerkt",
"badge": "BIJGEWERKT",
"body": "{{organizer}} heeft een evenement bijgewerkt waarvoor u bent uitgenodigd"
},
"cancel": {
"title": "Evenement geannuleerd",
"heading": "Evenement geannuleerd",
"badge": "GEANNULEERD",
"body": "{{organizer}} heeft het volgende evenement geannuleerd"
},
"reply": {
"title": "Antwoord op evenement",
"heading": "Antwoord ontvangen",
"body": "{{attendee}} heeft gereageerd op uw evenement"
},
"labels": {
"when": "Wanneer",
"until": "tot",
"location": "Locatie",
"videoConference": "Videoconferentie",
"organizer": "Organisator",
"attendee": "Deelnemer",
"description": "Beschrijving",
"wasScheduledFor": "Was gepland op"
},
"actions": {
"accept": "Accepteren",
"maybe": "Misschien",
"decline": "Weigeren"
},
"instructions": {
"invitation": "U kunt ook het bijgevoegde .ics-bestand openen met uw agendatoepassing.",
"update": "Dit evenement is bijgewerkt. U kunt ook het bijgevoegde .ics-bestand openen om uw agenda bij te werken.",
"cancel": "Dit evenement is geannuleerd. Open het bijgevoegde .ics-bestand om het uit uw agenda te verwijderen.",
"reply": "Het antwoord van de deelnemer is geregistreerd. Open het bijgevoegde .ics-bestand om uw agenda bij te werken."
},
"footer": {
"invitation": "Deze uitnodiging is verzonden via {{appName}}",
"notification": "Deze melding is verzonden via {{appName}}"
},
"replyTo": {
"body": "{{attendee}} heeft gereageerd op uw evenement: {{summary}}",
"details": "Evenementdetails",
"instruction": "Het antwoord van de deelnemer is geregistreerd in het bijgevoegde .ics-bestand."
},
"cancelDetails": {
"body": "{{organizer}} heeft het evenement geannuleerd: {{summary}}",
"details": "Details van het geannuleerde evenement",
"instruction": "Dit evenement is geannuleerd. U kunt het uit uw agenda verwijderen door het bijgevoegde .ics-bestand te openen."
},
"updateDetails": {
"body": "{{organizer}} heeft het evenement bijgewerkt",
"details": "Bijgewerkte evenementdetails"
},
"invitationDetails": {
"body": "{{organizer}} nodigt u uit voor een evenement",
"details": "Evenementdetails",
"respond": "Reageer op deze uitnodiging:"
}
},
"rsvp": {
"accepted": "U heeft de uitnodiging geaccepteerd",
"tentative": "U heeft \"misschien\" geantwoord op de uitnodiging",
"declined": "U heeft de uitnodiging afgewezen",
"responseSent": "Uw antwoord is naar de organisator gestuurd.",
"error": {
"title": "Fout",
"invalidLink": "Ongeldige link",
"invalidAction": "Ongeldige actie.",
"invalidToken": "Deze link is ongeldig of verlopen.",
"invalidPayload": "Deze link is ongeldig.",
"eventNotFound": "Het evenement is niet gevonden. Het is mogelijk verwijderd.",
"eventPast": "Dit evenement is al voorbij.",
"notAttendee": "U staat niet vermeld als deelnemer van dit evenement.",
"updateFailed": "Er is een fout opgetreden bij het bijwerken. Probeer het opnieuw."
}
}
}

View File

@@ -24,6 +24,35 @@ server {
proxy_pass http://backend_server;
}
# CalDAV well-known discovery
location = /.well-known/caldav {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Django views (RSVP, iCal export)
location ^~ /rsvp/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
location ^~ /ical/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Django admin
location ^~ /admin/ {
proxy_set_header X-Forwarded-Proto https;
@@ -48,7 +77,7 @@ server {
# Frontend export
location / {
try_files $uri index.html $uri/ =404;
try_files $uri $uri.html $uri/ =404;
}
}