✨(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/`)
|
### CalDAV Server (`docker/sabredav/`)
|
||||||
PHP SabreDAV server providing CalDAV protocol support, running against the shared PostgreSQL database.
|
PHP SabreDAV server providing CalDAV protocol support, running against the shared PostgreSQL database.
|
||||||
|
|
||||||
### Service Ports (Development)
|
**IMPORTANT: Never query the SabreDAV database tables directly from Django.** Always interact with CalDAV through the SabreDAV HTTP API (PROPFIND, REPORT, PUT, etc.).
|
||||||
- Frontend: http://localhost:8920
|
|
||||||
- Backend API: http://localhost:8921
|
### Development Services
|
||||||
- CalDAV: http://localhost:8922
|
|
||||||
- Keycloak: http://localhost:8925
|
| Service | URL / Port | Description |
|
||||||
- PostgreSQL: 8926
|
|---------|------------|-------------|
|
||||||
- Mailcatcher: http://localhost:1081
|
| **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
|
## Key Technologies
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ if [ -n "$DATABASE_URL" ] && [ -z "$PGHOST" ]; then
|
|||||||
eval "$(python3 -c "
|
eval "$(python3 -c "
|
||||||
import os, urllib.parse
|
import os, urllib.parse
|
||||||
u = urllib.parse.urlparse(os.environ['DATABASE_URL'])
|
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 PGHOST=\"{u.hostname}\"')
|
||||||
print(f'export PGPORT=\"{u.port or 5432}\"')
|
print(f'export PGPORT=\"{u.port or 5432}\"')
|
||||||
print(f'export PGDATABASE=\"{u.path.lstrip(\"/\")}\"')
|
print(f'export PGDATABASE=\"{u.path.lstrip(\"/\")}\"')
|
||||||
print(f'export PGUSER=\"{u.username}\"')
|
print(f'export PGUSER=\"{u.username}\"')
|
||||||
print(f'export PGPASSWORD=\"{urllib.parse.unquote(u.password)}\"')
|
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)"
|
echo "-----> Parsed DATABASE_URL into PG* vars (host=$PGHOST port=$PGPORT db=$PGDATABASE)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ echo "-----> Running post-frontend script"
|
|||||||
mkdir -p build/
|
mkdir -p build/
|
||||||
mv src/frontend/apps/calendars/out build/frontend-out
|
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/backend/* ./
|
||||||
mv src/nginx/* ./
|
mv src/nginx/* ./
|
||||||
|
|
||||||
@@ -21,31 +23,72 @@ PHP_PREFIX=".php"
|
|||||||
DEB_DIR="/tmp/php-debs"
|
DEB_DIR="/tmp/php-debs"
|
||||||
mkdir -p "$DEB_DIR" "$PHP_PREFIX"
|
mkdir -p "$DEB_DIR" "$PHP_PREFIX"
|
||||||
|
|
||||||
BASE_URL="http://security.ubuntu.com/ubuntu/pool/main/p/php8.3"
|
# Hardcoded Launchpad URLs for PHP 8.3.6-0maysync1 (Ubuntu Noble amd64)
|
||||||
VERSION="8.3.6-0ubuntu0.24.04.6"
|
# 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
|
for pkg in "${!PHP_DEBS[@]}"; do
|
||||||
echo " Downloading php8.3-${pkg}"
|
echo " Downloading ${pkg}"
|
||||||
curl -fsSL -o "$DEB_DIR/php8.3-${pkg}.deb" \
|
curl -fsSL -o "$DEB_DIR/${pkg}.deb" "${PHP_DEBS[$pkg]}"
|
||||||
"${BASE_URL}/php8.3-${pkg}_${VERSION}_amd64.deb"
|
|
||||||
done
|
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
|
for deb in "$DEB_DIR"/*.deb; do
|
||||||
dpkg-deb -x "$deb" "$PHP_PREFIX"
|
dpkg-deb -x "$deb" "$PHP_PREFIX"
|
||||||
done
|
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'
|
cat > bin/php << 'WRAPPER'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
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" -c "$DIR/.php/php.ini" -n "$@"
|
||||||
exec "$DIR/.php/usr/bin/php8.3" "$@"
|
|
||||||
WRAPPER
|
WRAPPER
|
||||||
chmod +x bin/php
|
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
|
# Download Composer and install SabreDAV dependencies
|
||||||
echo "-----> Installing SabreDAV dependencies"
|
echo "-----> Installing SabreDAV dependencies"
|
||||||
@@ -53,6 +96,6 @@ curl -fsSL -o bin/composer.phar \
|
|||||||
https://getcomposer.org/download/latest-stable/composer.phar
|
https://getcomposer.org/download/latest-stable/composer.phar
|
||||||
cp -r docker/sabredav sabredav
|
cp -r docker/sabredav sabredav
|
||||||
cd 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
|
--no-dev --optimize-autoloader --no-interaction
|
||||||
cd ..
|
cd ..
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
source bin/export_pg_vars.sh
|
source bin/export_pg_vars.sh
|
||||||
|
|
||||||
# Start PHP-FPM for SabreDAV (CalDAV server)
|
# 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 \
|
--fpm-config /app/sabredav/php-fpm.conf \
|
||||||
--nodaemonize &
|
--nodaemonize &
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:5
|
image: redis:5
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "8924:6379"
|
||||||
|
|
||||||
mailcatcher:
|
mailcatcher:
|
||||||
image: sj26/mailcatcher:latest
|
image: sj26/mailcatcher:latest
|
||||||
ports:
|
ports:
|
||||||
- "1081:1080"
|
- "8927:1080"
|
||||||
|
|
||||||
backend-dev:
|
backend-dev:
|
||||||
build:
|
build:
|
||||||
@@ -44,6 +44,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
|
- ./src/frontend/apps/calendars/src/features/i18n/translations.json:/data/translations.json:ro
|
||||||
- /app/.venv
|
- /app/.venv
|
||||||
networks:
|
networks:
|
||||||
- lasuite
|
- lasuite
|
||||||
@@ -76,6 +77,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
|
- ./src/frontend/apps/calendars/src/features/i18n/translations.json:/data/translations.json:ro
|
||||||
- /app/.venv
|
- /app/.venv
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
|
|||||||
@@ -12,6 +12,23 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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
|
# Frontend - proxy to Next.js dev server
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend-dev:3000;
|
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)
|
// Get base URI from environment variable (set by compose.yaml)
|
||||||
// This ensures sabre/dav generates URLs with the correct proxy path
|
// 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
|
// Database connection from environment variables
|
||||||
$dbHost = getenv('PGHOST') ?: 'postgresql';
|
$dbHost = getenv('PGHOST') ?: 'postgresql';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ PGPORT=5432
|
|||||||
PGDATABASE=calendars
|
PGDATABASE=calendars
|
||||||
PGUSER=pgroot
|
PGUSER=pgroot
|
||||||
PGPASSWORD=pass
|
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_INBOUND_API_KEY=changeme-inbound-in-production
|
||||||
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
|
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production
|
||||||
# Default callback URL for sending scheduling notifications (emails)
|
# 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_NAME = values.Value("Calendrier", environ_name="APP_NAME", environ_prefix=None)
|
||||||
APP_URL = values.Value("", environ_name="APP_URL", 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
|
# Security
|
||||||
ALLOWED_HOSTS = values.ListValue([])
|
ALLOWED_HOSTS = values.ListValue([])
|
||||||
@@ -857,7 +865,7 @@ class Development(Base):
|
|||||||
DEFAULT_FROM_EMAIL = "calendars@calendars.world"
|
DEFAULT_FROM_EMAIL = "calendars@calendars.world"
|
||||||
CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world"
|
CALENDAR_INVITATION_FROM_EMAIL = "calendars@calendars.world"
|
||||||
APP_NAME = "Calendrier (Dev)"
|
APP_NAME = "Calendrier (Dev)"
|
||||||
APP_URL = "http://localhost:8920"
|
APP_URL = "http://localhost:8921"
|
||||||
|
|
||||||
DEBUG_TOOLBAR_CONFIG = {
|
DEBUG_TOOLBAR_CONFIG = {
|
||||||
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
|
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
|
||||||
@@ -945,6 +953,7 @@ class Production(Base):
|
|||||||
SECURE_REDIRECT_EXEMPT = [
|
SECURE_REDIRECT_EXEMPT = [
|
||||||
"^__lbheartbeat__",
|
"^__lbheartbeat__",
|
||||||
"^__heartbeat__",
|
"^__heartbeat__",
|
||||||
|
r"^api/v1\.0/caldav-scheduling-callback/",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -19,7 +17,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
|
|||||||
from rest_framework.throttling import UserRateThrottle
|
from rest_framework.throttling import UserRateThrottle
|
||||||
|
|
||||||
from core import models
|
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 core.services.import_service import MAX_FILE_SIZE, ICSImportService
|
||||||
|
|
||||||
from . import permissions, serializers
|
from . import permissions, serializers
|
||||||
@@ -261,47 +263,6 @@ class ConfigView(drf.views.APIView):
|
|||||||
return theme_customization
|
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):
|
class CalendarViewSet(viewsets.GenericViewSet):
|
||||||
"""ViewSet for calendar operations.
|
"""ViewSet for calendar operations.
|
||||||
|
|
||||||
@@ -354,10 +315,10 @@ class CalendarViewSet(viewsets.GenericViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
caldav_path = _normalize_caldav_path(caldav_path)
|
caldav_path = normalize_caldav_path(caldav_path)
|
||||||
|
|
||||||
# Verify user access
|
# 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(
|
return drf_response.Response(
|
||||||
{"error": "You don't have access to this calendar"},
|
{"error": "You don't have access to this calendar"},
|
||||||
status=status.HTTP_403_FORBIDDEN,
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
@@ -429,7 +390,7 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet):
|
|||||||
calendar_name = create_serializer.validated_data.get("calendar_name", "")
|
calendar_name = create_serializer.validated_data.get("calendar_name", "")
|
||||||
|
|
||||||
# Verify user has access to this calendar
|
# 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(
|
return drf_response.Response(
|
||||||
{"error": "You don't have access to this calendar"},
|
{"error": "You don't have access to this calendar"},
|
||||||
status=status.HTTP_403_FORBIDDEN,
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
@@ -468,10 +429,10 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
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
|
# 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(
|
return drf_response.Response(
|
||||||
{"error": "You don't have access to this calendar"},
|
{"error": "You don't have access to this calendar"},
|
||||||
status=status.HTTP_403_FORBIDDEN,
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from core.services.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path
|
||||||
from core.services.calendar_invitation_service import calendar_invitation_service
|
from core.services.calendar_invitation_service import calendar_invitation_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -45,44 +46,37 @@ class CalDAVProxyView(View):
|
|||||||
return HttpResponse(status=401)
|
return HttpResponse(status=401)
|
||||||
|
|
||||||
# Build the CalDAV server URL
|
# Build the CalDAV server URL
|
||||||
caldav_url = settings.CALDAV_URL
|
|
||||||
path = kwargs.get("path", "")
|
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)
|
# Use user email as the principal (CalDAV server uses email as username)
|
||||||
user_principal = request.user.email
|
user_principal = request.user.email
|
||||||
|
|
||||||
# Build target URL - CalDAV server uses base URI /api/v1.0/caldav/
|
http = CalDAVHTTPClient()
|
||||||
# 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)
|
# Build target URL
|
||||||
base_uri_path = "/api/v1.0/caldav"
|
|
||||||
clean_path = path.lstrip("/") if path else ""
|
clean_path = path.lstrip("/") if path else ""
|
||||||
|
|
||||||
# Construct target URL - always include the base URI path
|
|
||||||
if clean_path:
|
if clean_path:
|
||||||
target_url = f"{caldav_url}{base_uri_path}/{clean_path}"
|
target_url = http.build_url(clean_path)
|
||||||
else:
|
else:
|
||||||
# Root request - use base URI path
|
target_url = http.build_url("")
|
||||||
target_url = f"{caldav_url}{base_uri_path}/"
|
|
||||||
|
|
||||||
# Prepare headers for CalDAV server
|
# Prepare headers — start with shared auth headers, add proxy-specific ones
|
||||||
# CalDAV server uses custom auth backend that requires X-Forwarded-User header and API key
|
try:
|
||||||
headers = {
|
headers = CalDAVHTTPClient.build_base_headers(user_principal)
|
||||||
"Content-Type": request.content_type or "application/xml",
|
except ValueError:
|
||||||
"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:
|
|
||||||
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
|
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=500, content="CalDAV authentication not configured"
|
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)
|
# Add callback URL for CalDAV scheduling (iTip/iMip)
|
||||||
# The CalDAV server will call this URL when it needs to send invitations
|
# The CalDAV server will call this URL when it needs to send invitations
|
||||||
@@ -130,7 +124,7 @@ class CalDAVProxyView(View):
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
data=body,
|
data=body,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
timeout=30,
|
timeout=CalDAVHTTPClient.DEFAULT_TIMEOUT,
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import Http404, HttpResponse
|
from django.http import Http404, HttpResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
@@ -12,6 +11,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.models import CalendarSubscriptionToken
|
from core.models import CalendarSubscriptionToken
|
||||||
|
from core.services.caldav_service import CalDAVHTTPClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -50,69 +50,18 @@ class ICalExportView(View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Proxy to SabreDAV
|
# Proxy to SabreDAV
|
||||||
caldav_url = settings.CALDAV_URL
|
http = CalDAVHTTPClient()
|
||||||
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
|
try:
|
||||||
|
caldav_path = subscription.caldav_path.lstrip("/")
|
||||||
if not outbound_api_key:
|
response = http.request(
|
||||||
|
"GET",
|
||||||
|
subscription.owner.email,
|
||||||
|
caldav_path,
|
||||||
|
query="export",
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
|
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
|
||||||
return HttpResponse(status=500, content="iCal export 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:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error("CalDAV server error during iCal export: %s", str(e))
|
logger.error("CalDAV server error during iCal export: %s", str(e))
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -120,3 +69,34 @@ class ICalExportView(View):
|
|||||||
content="Calendar server unavailable",
|
content="Calendar server unavailable",
|
||||||
content_type="text/plain",
|
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."""
|
"""Services for CalDAV integration."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import unquote
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import icalendar
|
||||||
|
import requests
|
||||||
|
|
||||||
|
import caldav as caldav_lib
|
||||||
from caldav import DAVClient
|
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
|
from caldav.lib.error import NotFoundError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class CalDAVClient:
|
||||||
"""
|
"""
|
||||||
Client for communicating with CalDAV server using the caldav library.
|
Client for communicating with CalDAV server using the caldav library.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.base_url = settings.CALDAV_URL
|
self._http = CalDAVHTTPClient()
|
||||||
# Set the base URI path as expected by the CalDAV server
|
self.base_url = self._http.base_url
|
||||||
self.base_uri_path = "/api/v1.0/caldav/"
|
|
||||||
self.timeout = 30
|
|
||||||
|
|
||||||
def _get_client(self, user) -> DAVClient:
|
def _get_client(self, user) -> DAVClient:
|
||||||
"""
|
"""
|
||||||
@@ -32,33 +189,7 @@ class CalDAVClient:
|
|||||||
The CalDAV server requires API key authentication via Authorization header
|
The CalDAV server requires API key authentication via Authorization header
|
||||||
and X-Forwarded-User header for user identification.
|
and X-Forwarded-User header for user identification.
|
||||||
"""
|
"""
|
||||||
# CalDAV server base URL - include the base URI path that sabre/dav expects
|
return self._http.get_dav_client(user.email)
|
||||||
# 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_calendar_info(self, user, calendar_path: str) -> dict | None:
|
def get_calendar_info(self, user, calendar_path: str) -> dict | None:
|
||||||
"""
|
"""
|
||||||
@@ -72,18 +203,12 @@ class CalDAVClient:
|
|||||||
calendar = client.calendar(url=calendar_url)
|
calendar = client.calendar(url=calendar_url)
|
||||||
# Fetch properties
|
# Fetch properties
|
||||||
props = calendar.get_properties(
|
props = calendar.get_properties(
|
||||||
[
|
[DisplayName(), CalendarColor(), CalendarDescription()]
|
||||||
"{DAV:}displayname",
|
|
||||||
"{http://apple.com/ns/ical/}calendar-color",
|
|
||||||
"{urn:ietf:params:xml:ns:caldav}calendar-description",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
name = props.get("{DAV:}displayname", "Calendar")
|
name = props.get(DisplayName.tag, "Calendar")
|
||||||
color = props.get("{http://apple.com/ns/ical/}calendar-color", "#3174ad")
|
color = props.get(CalendarColor.tag, "#3174ad")
|
||||||
description = props.get(
|
description = props.get(CalendarDescription.tag, "")
|
||||||
"{urn:ietf:params:xml:ns:caldav}calendar-description", ""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clean up color (CalDAV may return with alpha channel like #RRGGBBAA)
|
# Clean up color (CalDAV may return with alpha channel like #RRGGBBAA)
|
||||||
if color and len(color) == 9 and color.startswith("#"):
|
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))
|
logger.error("Failed to get calendar info from CalDAV: %s", str(e))
|
||||||
return None
|
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.
|
Create a new calendar in CalDAV server for the given user.
|
||||||
Returns the CalDAV server path for the calendar.
|
Returns the CalDAV server path for the calendar.
|
||||||
@@ -114,6 +241,10 @@ class CalDAVClient:
|
|||||||
# Create calendar using caldav library
|
# Create calendar using caldav library
|
||||||
calendar = principal.make_calendar(name=calendar_name)
|
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}/
|
# CalDAV server calendar path format: /calendars/{username}/{calendar_id}/
|
||||||
# The caldav library returns a URL object, convert to string and extract path
|
# The caldav library returns a URL object, convert to string and extract path
|
||||||
calendar_url = str(calendar.url)
|
calendar_url = str(calendar.url)
|
||||||
@@ -390,12 +521,12 @@ class CalendarService:
|
|||||||
calendar_name = "Mon calendrier"
|
calendar_name = "Mon calendrier"
|
||||||
return self.caldav.create_calendar(user, calendar_name, calendar_id)
|
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"
|
self, user, name: str, color: str = "#3174ad"
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a new calendar for a user. Returns the caldav_path."""
|
"""Create a new calendar for a user. Returns the caldav_path."""
|
||||||
calendar_id = str(uuid4())
|
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:
|
def get_events(self, user, caldav_path: str, start=None, end=None) -> list:
|
||||||
"""Get events from a calendar. Returns parsed event data."""
|
"""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:
|
def delete_event(self, user, caldav_path: str, event_uid: str) -> None:
|
||||||
"""Delete an event."""
|
"""Delete an event."""
|
||||||
self.caldav.delete_event(user, caldav_path, event_uid)
|
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 import encoders
|
||||||
from email.mime.base import MIMEBase
|
from email.mime.base import MIMEBase
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.core.signing import Signer
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
# French month and day names for date formatting
|
from core.services.translation_service import TranslationService
|
||||||
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",
|
|
||||||
]
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -201,7 +187,7 @@ class ICalendarParser:
|
|||||||
|
|
||||||
# Extract basic properties from VEVENT block
|
# Extract basic properties from VEVENT block
|
||||||
uid = cls.extract_property(vevent_block, "UID")
|
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")
|
description = cls.extract_property(vevent_block, "DESCRIPTION")
|
||||||
location = cls.extract_property(vevent_block, "LOCATION")
|
location = cls.extract_property(vevent_block, "LOCATION")
|
||||||
url = cls.extract_property(vevent_block, "URL")
|
url = cls.extract_property(vevent_block, "URL")
|
||||||
@@ -338,22 +324,27 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
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
|
# Determine email type and get appropriate subject/content
|
||||||
if method == self.METHOD_CANCEL:
|
if method == self.METHOD_CANCEL:
|
||||||
subject = self._get_cancel_subject(event)
|
subject = t("email.subject.cancel", lang, summary=summary)
|
||||||
template_prefix = "calendar_invitation_cancel"
|
template_prefix = "calendar_invitation_cancel"
|
||||||
elif method == self.METHOD_REPLY:
|
elif method == self.METHOD_REPLY:
|
||||||
subject = self._get_reply_subject(event)
|
subject = t("email.subject.reply", lang, summary=summary)
|
||||||
template_prefix = "calendar_invitation_reply"
|
template_prefix = "calendar_invitation_reply"
|
||||||
elif event.sequence > 0:
|
elif event.sequence > 0:
|
||||||
subject = self._get_update_subject(event)
|
subject = t("email.subject.update", lang, summary=summary)
|
||||||
template_prefix = "calendar_invitation_update"
|
template_prefix = "calendar_invitation_update"
|
||||||
else:
|
else:
|
||||||
subject = self._get_invitation_subject(event)
|
subject = t("email.subject.invitation", lang, summary=summary)
|
||||||
template_prefix = "calendar_invitation"
|
template_prefix = "calendar_invitation"
|
||||||
|
|
||||||
# Build context for templates
|
# Build context for templates
|
||||||
context = self._build_template_context(event, method)
|
context = self._build_template_context(event, method, lang)
|
||||||
|
|
||||||
# Render email bodies
|
# Render email bodies
|
||||||
text_body = render_to_string(f"emails/{template_prefix}.txt", context)
|
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
|
return False
|
||||||
|
|
||||||
def _get_invitation_subject(self, event: EventDetails) -> str:
|
def _build_template_context( # pylint: disable=too-many-locals
|
||||||
"""Generate subject line for new invitation."""
|
self, event: EventDetails, method: str, lang: str = "fr"
|
||||||
return f"Invitation : {event.summary}"
|
) -> dict:
|
||||||
|
|
||||||
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:
|
|
||||||
"""Build context dictionary for email templates."""
|
"""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:
|
if event.is_all_day:
|
||||||
start_str = self._format_date_french(event.dtstart)
|
start_str = TranslationService.format_date(event.dtstart, lang)
|
||||||
end_str = (
|
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:
|
else:
|
||||||
time_format = "%H:%M"
|
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)
|
start_time = event.dtstart.strftime(time_format)
|
||||||
end_time = event.dtend.strftime(time_format) if event.dtend else ""
|
end_time = event.dtend.strftime(time_format) if event.dtend else ""
|
||||||
end_str = (
|
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
|
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,
|
"event": event,
|
||||||
|
"summary": summary,
|
||||||
"method": method,
|
"method": method,
|
||||||
"organizer_display": event.organizer_name or event.organizer_email,
|
"lang": lang,
|
||||||
"attendee_display": event.attendee_name or event.attendee_email,
|
"organizer_display": organizer_display,
|
||||||
|
"attendee_display": attendee_display,
|
||||||
"start_date": start_str,
|
"start_date": start_str,
|
||||||
"end_date": end_str,
|
"end_date": end_str,
|
||||||
"time_str": time_str,
|
"time_str": time_str,
|
||||||
@@ -433,28 +426,100 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
|
|||||||
"is_cancel": method == self.METHOD_CANCEL,
|
"is_cancel": method == self.METHOD_CANCEL,
|
||||||
"app_name": getattr(settings, "APP_NAME", "Calendrier"),
|
"app_name": getattr(settings, "APP_NAME", "Calendrier"),
|
||||||
"app_url": getattr(settings, "APP_URL", ""),
|
"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:
|
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
|
itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False)
|
||||||
if "METHOD:" not in icalendar_data.upper():
|
|
||||||
# Insert METHOD after VERSION
|
if itip_enabled:
|
||||||
icalendar_data = re.sub(
|
if "METHOD:" not in icalendar_data.upper():
|
||||||
r"(VERSION:2\.0\r?\n)",
|
icalendar_data = re.sub(
|
||||||
rf"\1METHOD:{method}\r\n",
|
r"(VERSION:2\.0\r?\n)",
|
||||||
icalendar_data,
|
rf"\1METHOD:{method}\r\n",
|
||||||
flags=re.IGNORECASE,
|
icalendar_data,
|
||||||
)
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
icalendar_data = re.sub(
|
||||||
|
r"METHOD:[^\r\n]+",
|
||||||
|
f"METHOD:{method}",
|
||||||
|
icalendar_data,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Update existing METHOD
|
# Strip any existing METHOD so clients treat it as a plain event
|
||||||
icalendar_data = re.sub(
|
icalendar_data = re.sub(
|
||||||
r"METHOD:[^\r\n]+",
|
r"METHOD:[^\r\n]+\r?\n",
|
||||||
f"METHOD:{method}",
|
"",
|
||||||
icalendar_data,
|
icalendar_data,
|
||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
@@ -503,13 +568,14 @@ class CalendarInvitationService: # pylint: disable=too-many-instance-attributes
|
|||||||
email.attach_alternative(html_body, "text/html")
|
email.attach_alternative(html_body, "text/html")
|
||||||
|
|
||||||
# Add ICS attachment with proper MIME type
|
# Add ICS attachment with proper MIME type
|
||||||
# The Content-Type must include method parameter for calendar clients
|
|
||||||
ics_attachment = MIMEBase("text", "calendar")
|
ics_attachment = MIMEBase("text", "calendar")
|
||||||
ics_attachment.set_payload(ics_content.encode("utf-8"))
|
ics_attachment.set_payload(ics_content.encode("utf-8"))
|
||||||
encoders.encode_base64(ics_attachment)
|
encoders.encode_base64(ics_attachment)
|
||||||
ics_attachment.add_header(
|
itip_enabled = getattr(settings, "CALENDAR_ITIP_ENABLED", False)
|
||||||
"Content-Type", f"text/calendar; charset=utf-8; method={ics_method}"
|
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(
|
ics_attachment.add_header(
|
||||||
"Content-Disposition", 'attachment; filename="invite.ics"'
|
"Content-Disposition", 'attachment; filename="invite.ics"'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from core.services.caldav_service import CalDAVHTTPClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||||
@@ -36,7 +36,7 @@ class ICSImportService:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
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:
|
def import_events(self, user, caldav_path: str, ics_data: bytes) -> ImportResult:
|
||||||
"""Import events from ICS data into a calendar.
|
"""Import events from ICS data into a calendar.
|
||||||
@@ -53,30 +53,26 @@ class ICSImportService:
|
|||||||
"""
|
"""
|
||||||
result = ImportResult()
|
result = ImportResult()
|
||||||
|
|
||||||
# Ensure caldav_path includes the base URI prefix that SabreDAV expects
|
try:
|
||||||
base_uri = "/api/v1.0/caldav/"
|
api_key = CalDAVHTTPClient.get_api_key()
|
||||||
if not caldav_path.startswith(base_uri):
|
except ValueError:
|
||||||
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:
|
|
||||||
result.errors.append("CALDAV_OUTBOUND_API_KEY is not configured")
|
result.errors.append("CALDAV_OUTBOUND_API_KEY is not configured")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
headers = {
|
# Timeout scales with file size: 60s base + 30s per MB of ICS data.
|
||||||
"Content-Type": "text/calendar",
|
# 8000 events (~4MB) took ~70s in practice.
|
||||||
"X-Api-Key": outbound_api_key,
|
timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30
|
||||||
"X-Forwarded-User": user.email,
|
|
||||||
"X-Calendars-Import": outbound_api_key,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Timeout scales with file size: 60s base + 30s per MB of ICS data.
|
response = self._http.request(
|
||||||
# 8000 events (~4MB) took ~70s in practice.
|
"POST",
|
||||||
timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30
|
user.email,
|
||||||
response = requests.post(
|
caldav_path,
|
||||||
url, data=ics_data, headers=headers, timeout=timeout
|
query="import",
|
||||||
|
data=ics_data,
|
||||||
|
content_type="text/calendar",
|
||||||
|
extra_headers={"X-Calendars-Import": api_key},
|
||||||
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
logger.error("Failed to reach SabreDAV import endpoint: %s", 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>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="{{ lang }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Invitation à un événement</title>
|
<title>{{ content.title }}</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
@@ -96,37 +96,37 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Invitation à un événement</h1>
|
<h1>{{ content.heading }}</h1>
|
||||||
</div>
|
</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">
|
<div class="event-details">
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Quand</td>
|
<td>{{ labels.when }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ start_date }}<br>
|
{{ start_date }}<br>
|
||||||
<strong>{{ time_str }}</strong>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if event.location %}
|
{% if event.location %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Lieu</td>
|
<td>{{ labels.location }}</td>
|
||||||
<td>{{ event.location }}</td>
|
<td>{{ event.location }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if event.url %}
|
{% if event.url %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Visio</td>
|
<td>{{ labels.videoConference }}</td>
|
||||||
<td><a href="{{ event.url }}" style="color: #0066cc;">{{ event.url }}</a></td>
|
<td><a href="{{ event.url }}" style="color: #0066cc;">{{ event.url }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Organisateur</td>
|
<td>{{ labels.organizer }}</td>
|
||||||
<td>{{ organizer_display }} <{{ event.organizer_email }}></td>
|
<td>{{ organizer_display }} <{{ event.organizer_email }}></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -134,17 +134,24 @@
|
|||||||
|
|
||||||
{% if event.description %}
|
{% if event.description %}
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<h3>Description</h3>
|
<h3>{{ labels.description }}</h3>
|
||||||
<p>{{ event.description|linebreaks }}</p>
|
<p>{{ event.description|linebreaks }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<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>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>Cette invitation a été envoyée via {{ app_name }}</p>
|
<p>{{ footer }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
{{ organizer_display }} vous invite à un événement
|
{{ content.body }}
|
||||||
|
|
||||||
Détails de l'événement
|
{{ summary }}
|
||||||
======================
|
======================
|
||||||
|
|
||||||
Titre : {{ event.summary }}
|
{{ labels.when }} : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
|
||||||
Quand : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
|
{% if event.location %}{{ labels.location }} : {{ event.location }}
|
||||||
{% if event.location %}Lieu : {{ event.location }}
|
{% endif %}{% if event.url %}{{ labels.videoConference }} : {{ event.url }}
|
||||||
{% endif %}{% if event.url %}Visio : {{ event.url }}
|
{% endif %}{{ labels.organizer }} : {{ organizer_display }} <{{ event.organizer_email }}>
|
||||||
{% endif %}Organisateur : {{ organizer_display }} <{{ event.organizer_email }}>
|
|
||||||
|
|
||||||
{% if event.description %}
|
{% if event.description %}
|
||||||
Description :
|
{{ labels.description }} :
|
||||||
{{ event.description }}
|
{{ event.description }}
|
||||||
{% endif %}
|
{% 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>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="{{ lang }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Événement annulé</title>
|
<title>{{ content.title }}</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
@@ -89,50 +89,50 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Événement annulé</h1>
|
<h1>{{ content.heading }}</h1>
|
||||||
</div>
|
</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">
|
<div class="event-details">
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Était prévu le</td>
|
<td>{{ labels.wasScheduledFor }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ start_date }}<br>
|
{{ start_date }}<br>
|
||||||
<strong>{{ time_str }}</strong>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if event.location %}
|
{% if event.location %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Lieu</td>
|
<td>{{ labels.location }}</td>
|
||||||
<td>{{ event.location }}</td>
|
<td>{{ event.location }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if event.url %}
|
{% if event.url %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Visio</td>
|
<td>{{ labels.videoConference }}</td>
|
||||||
<td>{{ event.url }}</td>
|
<td>{{ event.url }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Organisateur</td>
|
<td>{{ labels.organizer }}</td>
|
||||||
<td>{{ organizer_display }} <{{ event.organizer_email }}></td>
|
<td>{{ organizer_display }} <{{ event.organizer_email }}></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="instructions">
|
<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>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>Cette notification a été envoyée via {{ app_name }}</p>
|
<p>{{ footer }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</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 }}
|
{{ labels.wasScheduledFor }} : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
|
||||||
Était prévu le : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
|
{% if event.location %}{{ labels.location }} : {{ event.location }}
|
||||||
{% if event.location %}Lieu : {{ event.location }}
|
{% endif %}{% if event.url %}{{ labels.videoConference }} : {{ event.url }}
|
||||||
{% endif %}{% if event.url %}Visio : {{ event.url }}
|
{% endif %}{{ labels.organizer }} : {{ organizer_display }} <{{ event.organizer_email }}>
|
||||||
{% endif %}Organisateur : {{ 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>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="{{ lang }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Réponse à l'événement</title>
|
<title>{{ content.title }}</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
@@ -78,48 +78,48 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Réponse reçue</h1>
|
<h1>{{ content.heading }}</h1>
|
||||||
</div>
|
</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">
|
<div class="event-details">
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Quand</td>
|
<td>{{ labels.when }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ start_date }}<br>
|
{{ start_date }}<br>
|
||||||
<strong>{{ time_str }}</strong>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if event.location %}
|
{% if event.location %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Lieu</td>
|
<td>{{ labels.location }}</td>
|
||||||
<td>{{ event.location }}</td>
|
<td>{{ event.location }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if event.url %}
|
{% if event.url %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Visio</td>
|
<td>{{ labels.videoConference }}</td>
|
||||||
<td><a href="{{ event.url }}" style="color: #28a745;">{{ event.url }}</a></td>
|
<td><a href="{{ event.url }}" style="color: #28a745;">{{ event.url }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Participant</td>
|
<td>{{ labels.attendee }}</td>
|
||||||
<td>{{ attendee_display }} <{{ event.attendee_email }}></td>
|
<td>{{ attendee_display }} <{{ event.attendee_email }}></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="instructions">
|
<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>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>Cette notification a été envoyée via {{ app_name }}</p>
|
<p>{{ footer }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</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 }}
|
{{ labels.when }} : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
|
||||||
Quand : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
|
{% if event.location %}{{ labels.location }} : {{ event.location }}
|
||||||
{% if event.location %}Lieu : {{ event.location }}
|
{% endif %}{% if event.url %}{{ labels.videoConference }} : {{ event.url }}
|
||||||
{% endif %}{% if event.url %}Visio : {{ event.url }}
|
|
||||||
{% endif %}
|
{% 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>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="{{ lang }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Événement modifié</title>
|
<title>{{ content.title }}</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
@@ -99,39 +99,39 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Événement modifié</h1>
|
<h1>{{ content.heading }}</h1>
|
||||||
</div>
|
</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">
|
<div class="event-details">
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Quand</td>
|
<td>{{ labels.when }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ start_date }}<br>
|
{{ start_date }}<br>
|
||||||
<strong>{{ time_str }}</strong>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if event.location %}
|
{% if event.location %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Lieu</td>
|
<td>{{ labels.location }}</td>
|
||||||
<td>{{ event.location }}</td>
|
<td>{{ event.location }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if event.url %}
|
{% if event.url %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Visio</td>
|
<td>{{ labels.videoConference }}</td>
|
||||||
<td><a href="{{ event.url }}" style="color: #e65100;">{{ event.url }}</a></td>
|
<td><a href="{{ event.url }}" style="color: #e65100;">{{ event.url }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Organisateur</td>
|
<td>{{ labels.organizer }}</td>
|
||||||
<td>{{ organizer_display }} <{{ event.organizer_email }}></td>
|
<td>{{ organizer_display }} <{{ event.organizer_email }}></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -139,17 +139,24 @@
|
|||||||
|
|
||||||
{% if event.description %}
|
{% if event.description %}
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<h3>Description</h3>
|
<h3>{{ labels.description }}</h3>
|
||||||
<p>{{ event.description|linebreaks }}</p>
|
<p>{{ event.description|linebreaks }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<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>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>Cette notification a été envoyée via {{ app_name }}</p>
|
<p>{{ footer }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</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 }}
|
{{ labels.when }} : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
|
||||||
Quand : {{ start_date }} {{ time_str }}{% if start_date != end_date %} - {{ end_date }}{% endif %}
|
{% if event.location %}{{ labels.location }} : {{ event.location }}
|
||||||
{% if event.location %}Lieu : {{ event.location }}
|
{% endif %}{% if event.url %}{{ labels.videoConference }} : {{ event.url }}
|
||||||
{% endif %}{% if event.url %}Visio : {{ event.url }}
|
{% endif %}{{ labels.organizer }} : {{ organizer_display }} <{{ event.organizer_email }}>
|
||||||
{% endif %}Organisateur : {{ organizer_display }} <{{ event.organizer_email }}>
|
|
||||||
|
|
||||||
{% if event.description %}
|
{% if event.description %}
|
||||||
Description :
|
{{ labels.description }} :
|
||||||
{{ event.description }}
|
{{ event.description }}
|
||||||
{% endif %}
|
{% 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 (
|
from rest_framework.status import (
|
||||||
HTTP_200_OK,
|
HTTP_200_OK,
|
||||||
HTTP_207_MULTI_STATUS,
|
HTTP_207_MULTI_STATUS,
|
||||||
|
HTTP_400_BAD_REQUEST,
|
||||||
HTTP_401_UNAUTHORIZED,
|
HTTP_401_UNAUTHORIZED,
|
||||||
)
|
)
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories
|
from core import factories
|
||||||
|
from core.services.caldav_service import validate_caldav_proxy_path
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -305,3 +307,48 @@ class TestCalDAVProxy:
|
|||||||
assert response.status_code == HTTP_200_OK
|
assert response.status_code == HTTP_200_OK
|
||||||
assert "Access-Control-Allow-Methods" in response
|
assert "Access-Control-Allow-Methods" in response
|
||||||
assert "PROPFIND" in response["Access-Control-Allow-Methods"]
|
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 caldav_path is not None
|
||||||
assert isinstance(caldav_path, str)
|
assert isinstance(caldav_path, str)
|
||||||
assert "calendars/" in caldav_path
|
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:
|
class TestICSImportService:
|
||||||
"""Unit tests for ICSImportService with mocked HTTP call to SabreDAV."""
|
"""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):
|
def test_import_single_event(self, mock_post):
|
||||||
"""Importing a single event should succeed."""
|
"""Importing a single event should succeed."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -299,7 +299,7 @@ class TestICSImportService:
|
|||||||
call_kwargs = mock_post.call_args
|
call_kwargs = mock_post.call_args
|
||||||
assert call_kwargs.kwargs["data"] == ICS_SINGLE_EVENT
|
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):
|
def test_import_multiple_events(self, mock_post):
|
||||||
"""Importing multiple events should forward all to SabreDAV."""
|
"""Importing multiple events should forward all to SabreDAV."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -319,7 +319,7 @@ class TestICSImportService:
|
|||||||
# Single HTTP call, not one per event
|
# Single HTTP call, not one per event
|
||||||
mock_post.assert_called_once()
|
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):
|
def test_import_empty_ics(self, mock_post):
|
||||||
"""Importing an ICS with no events should return zero counts."""
|
"""Importing an ICS with no events should return zero counts."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -337,7 +337,7 @@ class TestICSImportService:
|
|||||||
assert result.skipped_count == 0
|
assert result.skipped_count == 0
|
||||||
assert not result.errors
|
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):
|
def test_import_invalid_ics(self, mock_post):
|
||||||
"""Importing invalid ICS data should return an error from SabreDAV."""
|
"""Importing invalid ICS data should return an error from SabreDAV."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -354,7 +354,7 @@ class TestICSImportService:
|
|||||||
assert result.imported_count == 0
|
assert result.imported_count == 0
|
||||||
assert len(result.errors) >= 1
|
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):
|
def test_import_with_timezone(self, mock_post):
|
||||||
"""Events with timezones should be forwarded to SabreDAV."""
|
"""Events with timezones should be forwarded to SabreDAV."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -375,7 +375,7 @@ class TestICSImportService:
|
|||||||
assert b"VTIMEZONE" in call_kwargs.kwargs["data"]
|
assert b"VTIMEZONE" in call_kwargs.kwargs["data"]
|
||||||
assert b"Europe/Paris" 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):
|
def test_import_partial_failure(self, mock_post):
|
||||||
"""When some events fail, SabreDAV reports partial success."""
|
"""When some events fail, SabreDAV reports partial success."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -404,7 +404,7 @@ class TestICSImportService:
|
|||||||
# Only event name is exposed, not raw error details
|
# Only event name is exposed, not raw error details
|
||||||
assert result.errors[0] == "Afternoon review"
|
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):
|
def test_import_all_day_event(self, mock_post):
|
||||||
"""All-day events should be forwarded to SabreDAV."""
|
"""All-day events should be forwarded to SabreDAV."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -420,7 +420,7 @@ class TestICSImportService:
|
|||||||
assert result.total_events == 1
|
assert result.total_events == 1
|
||||||
assert result.imported_count == 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):
|
def test_import_valarm_without_action(self, mock_post):
|
||||||
"""VALARM without ACTION is handled by SabreDAV plugin repair."""
|
"""VALARM without ACTION is handled by SabreDAV plugin repair."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -436,7 +436,7 @@ class TestICSImportService:
|
|||||||
assert result.total_events == 1
|
assert result.total_events == 1
|
||||||
assert result.imported_count == 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):
|
def test_import_recurring_with_exception(self, mock_post):
|
||||||
"""Recurring event + modified occurrence handled by SabreDAV splitter."""
|
"""Recurring event + modified occurrence handled by SabreDAV splitter."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -453,7 +453,7 @@ class TestICSImportService:
|
|||||||
assert result.total_events == 1
|
assert result.total_events == 1
|
||||||
assert result.imported_count == 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):
|
def test_import_event_missing_dtstart(self, mock_post):
|
||||||
"""Events without DTSTART handling is delegated to SabreDAV."""
|
"""Events without DTSTART handling is delegated to SabreDAV."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -480,7 +480,7 @@ class TestICSImportService:
|
|||||||
assert result.skipped_count == 1
|
assert result.skipped_count == 1
|
||||||
assert result.errors[0] == "Missing start"
|
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):
|
def test_import_passes_calendar_path(self, mock_post):
|
||||||
"""The import URL should include the caldav_path."""
|
"""The import URL should include the caldav_path."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -498,7 +498,7 @@ class TestICSImportService:
|
|||||||
assert caldav_path in url
|
assert caldav_path in url
|
||||||
assert "?import" 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):
|
def test_import_sends_auth_headers(self, mock_post):
|
||||||
"""The import request must include all required auth headers."""
|
"""The import request must include all required auth headers."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
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["X-Calendars-Import"] == settings.CALDAV_OUTBOUND_API_KEY
|
||||||
assert headers["Content-Type"] == "text/calendar"
|
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):
|
def test_import_duplicates_not_treated_as_errors(self, mock_post):
|
||||||
"""Duplicate events should be counted separately, not as errors."""
|
"""Duplicate events should be counted separately, not as errors."""
|
||||||
mock_post.return_value = _make_sabredav_response(
|
mock_post.return_value = _make_sabredav_response(
|
||||||
@@ -541,7 +541,7 @@ class TestICSImportService:
|
|||||||
assert result.skipped_count == 0
|
assert result.skipped_count == 0
|
||||||
assert not result.errors
|
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):
|
def test_import_network_failure(self, mock_post):
|
||||||
"""Network failures should return a graceful error."""
|
"""Network failures should return a graceful error."""
|
||||||
mock_post.side_effect = req.ConnectionError("Connection refused")
|
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 import viewsets
|
||||||
from core.api.viewsets_caldav import CalDAVProxyView, CalDAVSchedulingCallbackView
|
from core.api.viewsets_caldav import CalDAVProxyView, CalDAVSchedulingCallbackView
|
||||||
from core.api.viewsets_ical import ICalExportView
|
from core.api.viewsets_ical import ICalExportView
|
||||||
|
from core.api.viewsets_rsvp import RSVPView
|
||||||
from core.external_api import viewsets as external_api_viewsets
|
from core.external_api import viewsets as external_api_viewsets
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
@@ -54,6 +55,9 @@ urlpatterns = [
|
|||||||
ICalExportView.as_view(),
|
ICalExportView.as_view(),
|
||||||
name="ical-export",
|
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,
|
useCallback,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { CalDavService } from "../services/dav/CalDavService";
|
import { CalDavService } from "../services/dav/CalDavService";
|
||||||
import { EventCalendarAdapter } from "../services/dav/EventCalendarAdapter";
|
import { EventCalendarAdapter } from "../services/dav/EventCalendarAdapter";
|
||||||
import { caldavServerUrl, headers, fetchOptions } from "../utils/DavClient";
|
import { caldavServerUrl, headers, fetchOptions } from "../utils/DavClient";
|
||||||
@@ -17,6 +18,10 @@ import type {
|
|||||||
} from "../services/dav/types/caldav-service";
|
} from "../services/dav/types/caldav-service";
|
||||||
import type { CalendarApi } from "../components/scheduler/types";
|
import type { CalendarApi } from "../components/scheduler/types";
|
||||||
import { createCalendarApi } from "../api";
|
import { createCalendarApi } from "../api";
|
||||||
|
import {
|
||||||
|
addToast,
|
||||||
|
ToasterItem,
|
||||||
|
} from "@/features/ui/components/toaster/Toaster";
|
||||||
|
|
||||||
const HIDDEN_CALENDARS_KEY = "calendar-hidden-urls";
|
const HIDDEN_CALENDARS_KEY = "calendar-hidden-urls";
|
||||||
|
|
||||||
@@ -97,10 +102,13 @@ interface CalendarContextProviderProps {
|
|||||||
export const CalendarContextProvider = ({
|
export const CalendarContextProvider = ({
|
||||||
children,
|
children,
|
||||||
}: CalendarContextProviderProps) => {
|
}: CalendarContextProviderProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const calendarRef = useRef<CalendarApi | null>(null);
|
const calendarRef = useRef<CalendarApi | null>(null);
|
||||||
const caldavService = useMemo(() => new CalDavService(), []);
|
const caldavService = useMemo(() => new CalDavService(), []);
|
||||||
const adapter = useMemo(() => new EventCalendarAdapter(), []);
|
const adapter = useMemo(() => new EventCalendarAdapter(), []);
|
||||||
const [davCalendars, setDavCalendars] = useState<CalDavCalendar[]>([]);
|
const [davCalendars, setDavCalendars] = useState<CalDavCalendar[]>([]);
|
||||||
|
const davCalendarsRef = useRef<CalDavCalendar[]>([]);
|
||||||
|
davCalendarsRef.current = davCalendars;
|
||||||
const [visibleCalendarUrls, setVisibleCalendarUrls] = useState<Set<string>>(
|
const [visibleCalendarUrls, setVisibleCalendarUrls] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
@@ -139,17 +147,27 @@ export const CalendarContextProvider = ({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error("Error fetching calendars:", result.error);
|
console.error("Error fetching calendars:", result.error);
|
||||||
|
addToast(
|
||||||
|
<ToasterItem type="error" closeButton>
|
||||||
|
{t("calendar.error.fetchCalendars")}
|
||||||
|
</ToasterItem>,
|
||||||
|
);
|
||||||
setDavCalendars([]);
|
setDavCalendars([]);
|
||||||
setVisibleCalendarUrls(new Set());
|
setVisibleCalendarUrls(new Set());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading calendars:", error);
|
console.error("Error loading calendars:", error);
|
||||||
|
addToast(
|
||||||
|
<ToasterItem type="error" closeButton>
|
||||||
|
{t("calendar.error.fetchCalendars")}
|
||||||
|
</ToasterItem>,
|
||||||
|
);
|
||||||
setDavCalendars([]);
|
setDavCalendars([]);
|
||||||
setVisibleCalendarUrls(new Set());
|
setVisibleCalendarUrls(new Set());
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [caldavService]);
|
}, [caldavService, t]);
|
||||||
|
|
||||||
const toggleCalendarVisibility = useCallback((calendarUrl: string) => {
|
const toggleCalendarVisibility = useCallback((calendarUrl: string) => {
|
||||||
setVisibleCalendarUrls((prev) => {
|
setVisibleCalendarUrls((prev) => {
|
||||||
@@ -160,12 +178,13 @@ export const CalendarContextProvider = ({
|
|||||||
newVisible.add(calendarUrl);
|
newVisible.add(calendarUrl);
|
||||||
}
|
}
|
||||||
// Persist: store the hidden set (all known URLs minus visible)
|
// 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)));
|
const newHidden = new Set(allUrls.filter((url) => !newVisible.has(url)));
|
||||||
saveHiddenUrls(newHidden);
|
saveHiddenUrls(newHidden);
|
||||||
return newVisible;
|
return newVisible;
|
||||||
});
|
});
|
||||||
}, [davCalendars]);
|
}, []);
|
||||||
|
|
||||||
const createCalendar = useCallback(
|
const createCalendar = useCallback(
|
||||||
async (
|
async (
|
||||||
@@ -319,11 +338,21 @@ export const CalendarContextProvider = ({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} else if (isMounted) {
|
} else if (isMounted) {
|
||||||
console.error("Failed to connect to CalDAV:", result.error);
|
console.error("Failed to connect to CalDAV:", result.error);
|
||||||
|
addToast(
|
||||||
|
<ToasterItem type="error" closeButton>
|
||||||
|
{t("calendar.error.connection")}
|
||||||
|
</ToasterItem>,
|
||||||
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
console.error("Error connecting to CalDAV:", error);
|
console.error("Error connecting to CalDAV:", error);
|
||||||
|
addToast(
|
||||||
|
<ToasterItem type="error" closeButton>
|
||||||
|
{t("calendar.error.connection")}
|
||||||
|
</ToasterItem>,
|
||||||
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
convertIcsTimezone,
|
convertIcsTimezone,
|
||||||
generateIcsCalendar,
|
generateIcsCalendar,
|
||||||
type IcsCalendar,
|
type IcsCalendar,
|
||||||
|
type IcsDateObject,
|
||||||
type IcsEvent,
|
type IcsEvent,
|
||||||
} from 'ts-ics'
|
} from 'ts-ics'
|
||||||
import {
|
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(
|
async addExdateToEvent(
|
||||||
eventUrl: string,
|
eventUrl: string,
|
||||||
@@ -357,111 +361,34 @@ export class CalDavService {
|
|||||||
throw new Error(`Failed to fetch event: ${fetchResponse.status}`)
|
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)
|
// Parse ICS into structured object
|
||||||
// Match DTSTART that comes after BEGIN:VEVENT
|
const calendar = convertIcsCalendar(undefined, icsText)
|
||||||
const veventMatch = icsText.match(/BEGIN:VEVENT[\s\S]*?DTSTART(;[^\r\n]*)?:([^\r\n]+)/)
|
const event = calendar.events?.[0]
|
||||||
let exdateLine = ''
|
if (!event) {
|
||||||
|
throw new Error('No event found in ICS data')
|
||||||
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}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the RRULE line in the VEVENT block and add EXDATE after it
|
// Build EXDATE entry matching DTSTART format (DATE vs DATE-TIME, timezone)
|
||||||
const lines = icsText.split('\n')
|
const newExdate: IcsDateObject = {
|
||||||
const newLines: string[] = []
|
date: exdateToAdd,
|
||||||
let exdateAdded = false
|
type: event.start.type,
|
||||||
let inVEvent = false
|
local: event.start.local ? {
|
||||||
|
date: exdateToAdd,
|
||||||
// Extract just the date value from our exdateLine for appending
|
timezone: event.start.local.timezone,
|
||||||
const exdateValueMatch = exdateLine.match(/:([^\r\n]+)$/)
|
tzoffset: event.start.local.tzoffset,
|
||||||
const exdateValue = exdateValueMatch ? exdateValueMatch[1] : ''
|
} : undefined,
|
||||||
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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, {
|
const updateResponse = await fetch(eventUrl, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -469,7 +396,7 @@ export class CalDavService {
|
|||||||
...(etag ? { 'If-Match': etag } : {}),
|
...(etag ? { 'If-Match': etag } : {}),
|
||||||
...this._account?.headers,
|
...this._account?.headers,
|
||||||
},
|
},
|
||||||
body: icsText,
|
body: updatedIcsText,
|
||||||
...this._account?.fetchOptions,
|
...this._account?.fetchOptions,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
* Used by CalendarContext to initialize CalDavService.
|
* 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 = {
|
export const headers = {
|
||||||
"Content-Type": "application/xml",
|
"Content-Type": "application/xml",
|
||||||
|
|||||||
@@ -91,6 +91,10 @@
|
|||||||
"seconds_ago": "few seconds ago"
|
"seconds_ago": "few seconds ago"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
|
"error": {
|
||||||
|
"connection": "Failed to connect to the calendar server. Please try again later.",
|
||||||
|
"fetchCalendars": "Failed to load calendars. Please try again later."
|
||||||
|
},
|
||||||
"views": {
|
"views": {
|
||||||
"day": "Day",
|
"day": "Day",
|
||||||
"week": "Week",
|
"week": "Week",
|
||||||
@@ -319,6 +323,108 @@
|
|||||||
"organizer": "Organizer",
|
"organizer": "Organizer",
|
||||||
"viewProfile": "View profile",
|
"viewProfile": "View profile",
|
||||||
"cannotRemoveOrganizer": "Cannot remove organizer"
|
"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"
|
"seconds_ago": "il y a quelques secondes"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"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": {
|
"views": {
|
||||||
"day": "Jour",
|
"day": "Jour",
|
||||||
"week": "Semaine",
|
"week": "Semaine",
|
||||||
@@ -946,6 +1056,108 @@
|
|||||||
"organizer": "Organisateur",
|
"organizer": "Organisateur",
|
||||||
"viewProfile": "Voir le profil",
|
"viewProfile": "Voir le profil",
|
||||||
"cannotRemoveOrganizer": "Impossible de retirer l'organisateur"
|
"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"
|
"seconds_ago": "een paar seconden geleden"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
|
"error": {
|
||||||
|
"connection": "Kan geen verbinding maken met de kalenderserver. Probeer het later opnieuw.",
|
||||||
|
"fetchCalendars": "Kan kalenders niet laden. Probeer het later opnieuw."
|
||||||
|
},
|
||||||
"views": {
|
"views": {
|
||||||
"day": "Dag",
|
"day": "Dag",
|
||||||
"week": "Week",
|
"week": "Week",
|
||||||
@@ -1320,6 +1536,108 @@
|
|||||||
"organizer": "Organisator",
|
"organizer": "Organisator",
|
||||||
"viewProfile": "Profiel bekijken",
|
"viewProfile": "Profiel bekijken",
|
||||||
"cannotRemoveOrganizer": "Kan organisator niet verwijderen"
|
"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;
|
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
|
# Django admin
|
||||||
location ^~ /admin/ {
|
location ^~ /admin/ {
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
@@ -48,7 +77,7 @@ server {
|
|||||||
|
|
||||||
# Frontend export
|
# Frontend export
|
||||||
location / {
|
location / {
|
||||||
try_files $uri index.html $uri/ =404;
|
try_files $uri $uri.html $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user