✨(invitations) add invitation RSVP links in HTML emails (#10)
Also include many fixes and scalingo deployment
This commit is contained in:
21
CLAUDE.md
21
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ..
|
||||
|
||||
@@ -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 &
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
164
src/backend/core/api/viewsets_rsvp.py
Normal file
164
src/backend/core/api/viewsets_rsvp.py
Normal 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": "✅", # green check
|
||||
"tentative": "❓", # question mark
|
||||
"declined": "❌", # 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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"'
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
152
src/backend/core/services/translation_service.py
Normal file
152
src/backend/core/services/translation_service.py
Normal 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
|
||||
@@ -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 }} <{{ event.organizer_email }}></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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }} <{{ event.organizer_email }}></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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }} <{{ event.attendee_email }}></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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }} <{{ event.organizer_email }}></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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
76
src/backend/core/templates/rsvp/response.html
Normal file
76
src/backend/core/templates/rsvp/response.html
Normal 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">❌</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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
622
src/backend/core/tests/test_rsvp.py
Normal file
622
src/backend/core/tests/test_rsvp.py
Normal 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("&", "&")
|
||||
|
||||
# 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("&", "&")
|
||||
|
||||
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("&", "&")
|
||||
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()
|
||||
93
src/backend/core/tests/test_translation_service.py
Normal file
93
src/backend/core/tests/test_translation_service.py
Normal 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
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user