From 3a0f64e791f053817fce186b85af4a4923ba4471 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Mon, 9 Feb 2026 18:43:49 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(import)=20add=20import=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronous for now, can be offloaded to worker later. Also lint the codebase --- CLAUDE.md | 2 +- Makefile | 1 + compose.yaml | 7 +- docker/sabredav/Dockerfile | 7 +- docker/sabredav/composer.json | 8 +- docker/sabredav/server.php | 63 +- .../sabredav/src/AttendeeNormalizerPlugin.php | 43 +- .../sabredav/src/CalendarSanitizerPlugin.php | 234 ++++ docker/sabredav/src/ICSImportPlugin.php | 278 +++++ env.d/development/caldav.defaults | 9 +- src/backend/core/api/serializers.py | 14 +- src/backend/core/api/viewsets.py | 86 +- src/backend/core/api/viewsets_caldav.py | 31 +- src/backend/core/services/caldav_service.py | 23 +- .../services/calendar_invitation_service.py | 41 +- src/backend/core/services/import_service.py | 115 ++ src/backend/core/signals.py | 4 +- .../core/tests/test_caldav_scheduling.py | 30 +- src/backend/core/tests/test_import_events.py | 1088 +++++++++++++++++ .../apps/calendars/public/assets/favicon.png | Bin 670 -> 621 bytes .../calendars/src/features/api/fetchApi.ts | 34 + .../calendars/src/features/calendar/api.ts | 34 +- .../calendar-list/CalendarItemMenu.tsx | 11 +- .../calendar-list/CalendarList.scss | 107 ++ .../components/calendar-list/CalendarList.tsx | 80 +- .../calendar-list/CalendarListItem.tsx | 4 + .../calendar-list/ImportEventsModal.tsx | 173 +++ .../components/calendar-list/types.ts | 2 + .../features/calendar/hooks/useCalendars.ts | 20 + .../src/features/i18n/translations.json | 48 +- 30 files changed, 2476 insertions(+), 121 deletions(-) create mode 100644 docker/sabredav/src/CalendarSanitizerPlugin.php create mode 100644 docker/sabredav/src/ICSImportPlugin.php create mode 100644 src/backend/core/services/import_service.py create mode 100644 src/backend/core/tests/test_import_events.py create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/calendar-list/ImportEventsModal.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 9cb0965..b6af64e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ PHP SabreDAV server providing CalDAV protocol support, running against the share - Backend API: http://localhost:8921 - CalDAV: http://localhost:8922 - Keycloak: http://localhost:8925 -- PostgreSQL: 8912 +- PostgreSQL: 8926 - Mailcatcher: http://localhost:1081 ## Key Technologies diff --git a/Makefile b/Makefile index 564be76..a67ee89 100644 --- a/Makefile +++ b/Makefile @@ -108,6 +108,7 @@ build: cache ?= # --no-cache build: ## build the project containers @$(MAKE) build-backend cache=$(cache) @$(MAKE) build-frontend cache=$(cache) + @$(MAKE) build-caldav cache=$(cache) .PHONY: build build-backend: cache ?= diff --git a/compose.yaml b/compose.yaml index b91ae2f..e02c558 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,7 +5,7 @@ services: postgresql: image: postgres:15 ports: - - "8912:5432" + - "8926:5432" healthcheck: test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] interval: 1s @@ -138,6 +138,11 @@ services: env_file: - env.d/development/caldav.defaults - env.d/development/caldav.local + volumes: + - ./docker/sabredav/server.php:/var/www/sabredav/server.php + - ./docker/sabredav/src:/var/www/sabredav/src + - ./docker/sabredav/sql:/var/www/sabredav/sql + - ./docker/sabredav/init-database.sh:/usr/local/bin/init-database.sh networks: - default - lasuite diff --git a/docker/sabredav/Dockerfile b/docker/sabredav/Dockerfile index e841569..aa84eb1 100644 --- a/docker/sabredav/Dockerfile +++ b/docker/sabredav/Dockerfile @@ -21,10 +21,8 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer # Create application directory WORKDIR /var/www/sabredav -# Copy composer files +# Copy composer files and install dependencies COPY composer.json ./ - -# Install sabre/dav and dependencies RUN composer install --no-dev --optimize-autoloader --no-interaction # Copy server configuration @@ -51,7 +49,8 @@ RUN a2enmod rewrite headers \ RUN echo "log_errors = On" >> /usr/local/etc/php/conf.d/error-logging.ini \ && echo "error_log = /proc/self/fd/2" >> /usr/local/etc/php/conf.d/error-logging.ini \ && echo "display_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \ - && echo "display_startup_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini + && echo "display_startup_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \ + && echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/error-logging.ini # Set permissions RUN chown -R www-data:www-data /var/www/sabredav \ diff --git a/docker/sabredav/composer.json b/docker/sabredav/composer.json index 66f22ef..94b6ad8 100644 --- a/docker/sabredav/composer.json +++ b/docker/sabredav/composer.json @@ -2,9 +2,15 @@ "name": "calendars/sabredav-server", "description": "sabre/dav CalDAV server for calendars", "type": "project", + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/sylvinus/dav" + } + ], "require": { "php": ">=8.1", - "sabre/dav": "^4.5", + "sabre/dav": "dev-master", "ext-pdo": "*", "ext-pdo_pgsql": "*" }, diff --git a/docker/sabredav/server.php b/docker/sabredav/server.php index ed0bbb6..f725344 100644 --- a/docker/sabredav/server.php +++ b/docker/sabredav/server.php @@ -12,7 +12,9 @@ use Sabre\DAV; use Calendars\SabreDav\AutoCreatePrincipalBackend; use Calendars\SabreDav\HttpCallbackIMipPlugin; use Calendars\SabreDav\ApiKeyAuthBackend; +use Calendars\SabreDav\CalendarSanitizerPlugin; use Calendars\SabreDav\AttendeeNormalizerPlugin; +use Calendars\SabreDav\ICSImportPlugin; // Composer autoloader require_once __DIR__ . '/vendor/autoload.php'; @@ -89,39 +91,56 @@ $server->addPlugin(new CalDAV\ICSExportPlugin()); $server->addPlugin(new DAV\Sharing\Plugin()); $server->addPlugin(new CalDAV\SharingPlugin()); -// Debug logging for sharing requests -$server->on('method:POST', function($request) { - $contentType = $request->getHeader('Content-Type'); - $path = $request->getPath(); - $body = $request->getBodyAsString(); - error_log("[sabre/dav] POST request received:"); - error_log("[sabre/dav] Path: " . $path); - error_log("[sabre/dav] Content-Type: " . $contentType); - error_log("[sabre/dav] Body: " . substr($body, 0, 1000)); - // Reset body stream position - $request->setBody($body); -}, 50); // Priority 50 to run early +// Debug logging for POST requests - commented out to avoid PII in logs +// Uncomment for local debugging only, never in production. +// $server->on('method:POST', function($request) { +// $contentType = $request->getHeader('Content-Type'); +// $path = $request->getPath(); +// $body = $request->getBodyAsString(); +// error_log("[sabre/dav] POST request received:"); +// error_log("[sabre/dav] Path: " . $path); +// error_log("[sabre/dav] Content-Type: " . $contentType); +// error_log("[sabre/dav] Body: " . substr($body, 0, 1000)); +// $request->setBody($body); +// }, 50); +// +// $server->on('afterMethod:POST', function($request, $response) { +// error_log("[sabre/dav] POST response status: " . $response->getStatus()); +// $body = $response->getBodyAsString(); +// if ($body) { +// error_log("[sabre/dav] POST response body: " . substr($body, 0, 500)); +// } +// }, 50); -// Debug: Log when share plugin processes request -$server->on('afterMethod:POST', function($request, $response) { - error_log("[sabre/dav] POST response status: " . $response->getStatus()); - $body = $response->getBodyAsString(); - if ($body) { - error_log("[sabre/dav] POST response body: " . substr($body, 0, 500)); - } -}, 50); - -// Debug: Log exceptions +// Log unhandled exceptions $server->on('exception', function($e) { error_log("[sabre/dav] Exception: " . get_class($e) . " - " . $e->getMessage()); error_log("[sabre/dav] Exception trace: " . $e->getTraceAsString()); }, 50); +// Add calendar sanitizer plugin (priority 85, runs before all other calendar plugins) +// Strips inline binary attachments (Outlook/Exchange base64 images) and truncates +// oversized DESCRIPTION fields. Applies to ALL CalDAV writes (PUT from any client). +$sanitizerStripAttachments = getenv('SANITIZER_STRIP_BINARY_ATTACHMENTS') !== 'false'; +$sanitizerMaxDescBytes = getenv('SANITIZER_MAX_DESCRIPTION_BYTES'); +$sanitizerMaxDescBytes = ($sanitizerMaxDescBytes !== false) ? (int)$sanitizerMaxDescBytes : 102400; +$sanitizerMaxResourceSize = getenv('SANITIZER_MAX_RESOURCE_SIZE'); +$sanitizerMaxResourceSize = ($sanitizerMaxResourceSize !== false) ? (int)$sanitizerMaxResourceSize : 1048576; +$server->addPlugin(new CalendarSanitizerPlugin( + $sanitizerStripAttachments, + $sanitizerMaxDescBytes, + $sanitizerMaxResourceSize +)); + // Add attendee normalizer plugin to fix duplicate attendees issue // This plugin normalizes attendee emails (lowercase) and deduplicates them // when processing calendar objects, fixing issues with REPLY handling $server->addPlugin(new AttendeeNormalizerPlugin()); +// Add ICS import plugin for bulk event import from a single POST request +// Only accessible via the X-Calendars-Import header (backend-only) +$server->addPlugin(new ICSImportPlugin($caldavBackend, $apiKey)); + // Add custom IMipPlugin that forwards scheduling messages via HTTP callback // This MUST be added BEFORE the Schedule\Plugin so that Schedule\Plugin finds it // The callback URL can be provided per-request via X-CalDAV-Callback-URL header diff --git a/docker/sabredav/src/AttendeeNormalizerPlugin.php b/docker/sabredav/src/AttendeeNormalizerPlugin.php index ed14c7b..4fccb2f 100644 --- a/docker/sabredav/src/AttendeeNormalizerPlugin.php +++ b/docker/sabredav/src/AttendeeNormalizerPlugin.php @@ -60,12 +60,14 @@ class AttendeeNormalizerPlugin extends ServerPlugin // Hook into calendar object creation and updates // Priority 90 to run before most other plugins but after authentication - $server->on('beforeCreateFile', [$this, 'beforeWriteCalendarObject'], 90); - $server->on('beforeWriteContent', [$this, 'beforeWriteCalendarObject'], 90); + // Note: beforeCreateFile and beforeWriteContent have different signatures + $server->on('beforeCreateFile', [$this, 'beforeCreateCalendarObject'], 90); + $server->on('beforeWriteContent', [$this, 'beforeUpdateCalendarObject'], 90); } /** - * Called before a calendar object is created or updated. + * Called before a calendar object is created. + * Signature: ($path, &$data, \Sabre\DAV\ICollection $parent, &$modified) * * @param string $path The path to the file * @param resource|string $data The data being written @@ -73,18 +75,41 @@ class AttendeeNormalizerPlugin extends ServerPlugin * @param bool $modified Whether the data was modified * @return void */ - public function beforeWriteCalendarObject($path, &$data, $parentNode = null, &$modified = false) + public function beforeCreateCalendarObject($path, &$data, $parentNode = null, &$modified = false) + { + $this->processCalendarData($path, $data, $modified); + } + + /** + * Called before a calendar object is updated. + * Signature: ($path, \Sabre\DAV\IFile $node, &$data, &$modified) + * + * @param string $path The path to the file + * @param \Sabre\DAV\IFile $node The existing file node + * @param resource|string $data The data being written + * @param bool $modified Whether the data was modified + * @return void + */ + public function beforeUpdateCalendarObject($path, $node, &$data, &$modified = false) + { + $this->processCalendarData($path, $data, $modified); + } + + /** + * Process calendar data to normalize and deduplicate attendees. + * + * @param string $path The path to the file + * @param resource|string &$data The data being written (modified in place) + * @param bool &$modified Whether the data was modified + * @return void + */ + private function processCalendarData($path, &$data, &$modified) { // Only process .ics files in calendar collections if (!preg_match('/\.ics$/i', $path)) { return; } - // Check if parent is a calendar collection - if ($parentNode && !($parentNode instanceof \Sabre\CalDAV\ICalendarObjectContainer)) { - return; - } - try { // Get the data as string if (is_resource($data)) { diff --git a/docker/sabredav/src/CalendarSanitizerPlugin.php b/docker/sabredav/src/CalendarSanitizerPlugin.php new file mode 100644 index 0000000..db83527 --- /dev/null +++ b/docker/sabredav/src/CalendarSanitizerPlugin.php @@ -0,0 +1,234 @@ +stripBinaryAttachments = $stripBinaryAttachments; + $this->maxDescriptionBytes = $maxDescriptionBytes; + $this->maxResourceSize = $maxResourceSize; + } + + public function getPluginName() + { + return 'calendar-sanitizer'; + } + + public function initialize(Server $server) + { + $this->server = $server; + + // Priority 85: run before AttendeeNormalizerPlugin (90) and CalDAV validation (100) + $server->on('beforeCreateFile', [$this, 'beforeCreateCalendarObject'], 85); + $server->on('beforeWriteContent', [$this, 'beforeUpdateCalendarObject'], 85); + } + + /** + * Called before a calendar object is created. + * Signature: ($path, &$data, \Sabre\DAV\ICollection $parent, &$modified) + */ + public function beforeCreateCalendarObject($path, &$data, $parentNode = null, &$modified = false) + { + $this->sanitizeCalendarData($path, $data, $modified); + } + + /** + * Called before a calendar object is updated. + * Signature: ($path, \Sabre\DAV\IFile $node, &$data, &$modified) + */ + public function beforeUpdateCalendarObject($path, $node, &$data, &$modified = false) + { + $this->sanitizeCalendarData($path, $data, $modified); + } + + /** + * Sanitize raw calendar data from a beforeCreateFile/beforeWriteContent hook. + */ + private function sanitizeCalendarData($path, &$data, &$modified) + { + // Only process .ics files + if (!preg_match('/\.ics$/i', $path)) { + return; + } + + + try { + // Get the data as string + if (is_resource($data)) { + $dataStr = stream_get_contents($data); + rewind($data); + } else { + $dataStr = $data; + } + + $vcalendar = Reader::read($dataStr); + + if (!$vcalendar instanceof VCalendar) { + return; + } + + if ($this->sanitizeVCalendar($vcalendar)) { + $data = $vcalendar->serialize(); + $modified = true; + } + + // Enforce max resource size after sanitization + $finalSize = is_string($data) ? strlen($data) : strlen($dataStr); + if ($this->maxResourceSize > 0 && $finalSize > $this->maxResourceSize) { + throw new InsufficientStorage( + "Calendar object size ({$finalSize} bytes) exceeds limit ({$this->maxResourceSize} bytes)" + ); + } + } catch (InsufficientStorage $e) { + // Re-throw size limit errors — these must reach the client as HTTP 507 + throw $e; + } catch (\Exception $e) { + // Log other errors but don't block the request + error_log("[CalendarSanitizerPlugin] Error processing calendar object: " . $e->getMessage()); + } + } + + /** + * Sanitize a parsed VCalendar object in-place. + * Strips binary attachments and truncates oversized descriptions. + * + * Also called by ICSImportPlugin for direct DB writes that bypass + * the HTTP layer (and thus don't trigger beforeCreateFile hooks). + * + * @return bool True if the VCalendar was modified. + */ + public function sanitizeVCalendar(VCalendar $vcalendar) + { + $wasModified = false; + + foreach ($vcalendar->getComponents() as $component) { + if ($component->name === 'VTIMEZONE') { + continue; + } + + // Strip inline binary attachments + if ($this->stripBinaryAttachments && isset($component->ATTACH)) { + $toRemove = []; + foreach ($component->select('ATTACH') as $attach) { + $valueParam = $attach->offsetGet('VALUE'); + $encodingParam = $attach->offsetGet('ENCODING'); + if ( + ($valueParam && strtoupper((string)$valueParam) === 'BINARY') || + ($encodingParam && strtoupper((string)$encodingParam) === 'BASE64') + ) { + $toRemove[] = $attach; + } + } + foreach ($toRemove as $attach) { + $component->remove($attach); + $wasModified = true; + } + } + + // Truncate oversized long text properties (DESCRIPTION, X-ALT-DESC, COMMENT) + if ($this->maxDescriptionBytes > 0) { + foreach (self::LONG_TEXT_PROPERTIES as $prop) { + if (isset($component->{$prop})) { + $val = (string)$component->{$prop}; + if (strlen($val) > $this->maxDescriptionBytes) { + $component->{$prop} = substr($val, 0, $this->maxDescriptionBytes) . '...'; + $wasModified = true; + } + } + } + } + + // Truncate oversized short text properties (SUMMARY, LOCATION) + foreach (self::SHORT_TEXT_PROPERTIES as $prop) { + if (isset($component->{$prop})) { + $val = (string)$component->{$prop}; + if (strlen($val) > self::MAX_SHORT_TEXT_BYTES) { + $component->{$prop} = substr($val, 0, self::MAX_SHORT_TEXT_BYTES) . '...'; + $wasModified = true; + } + } + } + } + + return $wasModified; + } + + /** + * Check that a VCalendar's serialized size is within the max resource limit. + * Called by ICSImportPlugin for the direct DB write path. + * + * @throws InsufficientStorage if the serialized size exceeds the limit. + */ + public function checkResourceSize(VCalendar $vcalendar) + { + if ($this->maxResourceSize <= 0) { + return; + } + + $size = strlen($vcalendar->serialize()); + if ($size > $this->maxResourceSize) { + throw new InsufficientStorage( + "Calendar object size ({$size} bytes) exceeds limit ({$this->maxResourceSize} bytes)" + ); + } + } + + public function getPluginInfo() + { + return [ + 'name' => $this->getPluginName(), + 'description' => 'Sanitizes calendar data (strips binary attachments, truncates descriptions)', + ]; + } +} diff --git a/docker/sabredav/src/ICSImportPlugin.php b/docker/sabredav/src/ICSImportPlugin.php new file mode 100644 index 0000000..a1d2169 --- /dev/null +++ b/docker/sabredav/src/ICSImportPlugin.php @@ -0,0 +1,278 @@ +caldavBackend = $caldavBackend; + $this->importApiKey = $importApiKey; + } + + public function getPluginName() + { + return 'ics-import'; + } + + public function initialize(Server $server) + { + $this->server = $server; + // Priority 90: runs before the debug logger (50) + $server->on('method:POST', [$this, 'httpPost'], 90); + } + + /** + * Handle POST requests with ?import query parameter. + * + * @return bool|null false to stop event propagation, null to let + * other handlers proceed. + */ + public function httpPost($request, $response) + { + // Only handle requests with ?import in the query string + $queryParams = $request->getQueryParameters(); + if (!array_key_exists('import', $queryParams)) { + return; + } + + // Verify the dedicated import header + $headerValue = $request->getHeader('X-Calendars-Import'); + if (!$headerValue || $headerValue !== $this->importApiKey) { + $response->setStatus(403); + $response->setHeader('Content-Type', 'application/json'); + $response->setBody(json_encode([ + 'error' => 'Forbidden: missing or invalid X-Calendars-Import header', + ])); + return false; + } + + // Resolve the calendar from the request path. + // getPath() returns a path relative to the base URI, e.g. + // "calendars/user@example.com/cal-uuid" + $path = $request->getPath(); + $parts = explode('/', trim($path, '/')); + + // Expect exactly: [calendars, , ] + if (count($parts) < 3 || $parts[0] !== 'calendars') { + error_log("[ICSImportPlugin] Invalid calendar path: " . $path); + $response->setStatus(400); + $response->setHeader('Content-Type', 'application/json'); + $response->setBody(json_encode([ + 'error' => 'Invalid calendar path', + ])); + return false; + } + + $principalUser = urldecode($parts[1]); + $calendarUri = $parts[2]; + $principalUri = 'principals/' . $principalUser; + + // Look up calendarId by iterating the user's calendars + $calendarId = $this->resolveCalendarId($principalUri, $calendarUri); + if ($calendarId === null) { + $response->setStatus(404); + $response->setHeader('Content-Type', 'application/json'); + $response->setBody(json_encode([ + 'error' => 'Calendar not found', + ])); + return false; + } + + // Read and parse the raw ICS body + $icsBody = $request->getBodyAsString(); + if (empty($icsBody)) { + $response->setStatus(400); + $response->setHeader('Content-Type', 'application/json'); + $response->setBody(json_encode([ + 'error' => 'Empty request body', + ])); + return false; + } + + try { + $vcal = VObject\Reader::read($icsBody); + } catch (\Exception $e) { + error_log("[ICSImportPlugin] Failed to parse ICS: " . $e->getMessage()); + $response->setStatus(400); + $response->setHeader('Content-Type', 'application/json'); + $response->setBody(json_encode([ + 'error' => 'Failed to parse ICS file', + ])); + return false; + } + + // Validate and auto-repair (fixes missing VALARM ACTION, etc.) + $vcal->validate(VObject\Component::REPAIR); + + // Split by UID using the stream-based splitter + // The splitter expects a stream, so we wrap the serialized data + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $vcal->serialize()); + rewind($stream); + + $splitter = new VObject\Splitter\ICalendar($stream); + + $totalEvents = 0; + $importedCount = 0; + $duplicateCount = 0; + $skippedCount = 0; + $errors = []; + + while ($splitVcal = $splitter->getNext()) { + $totalEvents++; + + try { + // Extract UID from the first VEVENT + $uid = null; + foreach ($splitVcal->VEVENT as $vevent) { + if (isset($vevent->UID)) { + $uid = (string)$vevent->UID; + break; + } + } + + if (!$uid) { + $uid = \Sabre\DAV\UUIDUtil::getUUID(); + } + + // Sanitize event data (strip attachments, truncate descriptions) + // and enforce max resource size + $this->sanitizeAndCheckSize($splitVcal); + + $objectUri = $uid . '.ics'; + $data = $splitVcal->serialize(); + + $this->caldavBackend->createCalendarObject( + $calendarId, + $objectUri, + $data + ); + $importedCount++; + } catch (\Exception $e) { + $msg = $e->getMessage(); + $summary = ''; + if (isset($splitVcal->VEVENT) && isset($splitVcal->VEVENT->SUMMARY)) { + $summary = (string)$splitVcal->VEVENT->SUMMARY; + } + + // Duplicate key (SQLSTATE 23505) = event already exists + // "no valid instances" = dead recurring event (all occurrences excluded) + // Neither is actionable by the user, skip silently. + if (strpos($msg, '23505') !== false) { + $duplicateCount++; + } elseif (strpos($msg, 'valid instances') !== false) { + $skippedCount++; + } else { + $skippedCount++; + if (count($errors) < 10) { + $errors[] = [ + 'uid' => $uid ?? 'unknown', + 'summary' => $summary, + 'error' => $msg, + ]; + } + error_log( + "[ICSImportPlugin] Failed to import event " + . "uid=" . ($uid ?? 'unknown') + . " summary={$summary}: {$msg}" + ); + } + } + } + + fclose($stream); + + error_log( + "[ICSImportPlugin] Import complete: " + . "{$importedCount} imported, " + . "{$duplicateCount} duplicates, " + . "{$skippedCount} failed " + . "out of {$totalEvents} total" + ); + + $response->setStatus(200); + $response->setHeader('Content-Type', 'application/json'); + $response->setBody(json_encode([ + 'total_events' => $totalEvents, + 'imported_count' => $importedCount, + 'duplicate_count' => $duplicateCount, + 'skipped_count' => $skippedCount, + 'errors' => $errors, + ])); + + return false; + } + + /** + * Sanitize a split VCALENDAR before import and enforce max resource size. + * + * Delegates to CalendarSanitizerPlugin (if registered). Import bypasses + * the HTTP layer (uses createCalendarObject directly), so beforeCreateFile + * hooks don't fire — we must call the sanitizer explicitly. + * + * @throws \Exception if the sanitized object exceeds the max resource size. + */ + private function sanitizeAndCheckSize(VObject\Component\VCalendar $vcal) + { + $sanitizer = $this->server->getPlugin('calendar-sanitizer'); + if ($sanitizer) { + $sanitizer->sanitizeVCalendar($vcal); + $sanitizer->checkResourceSize($vcal); + } + } + + /** + * Resolve the internal calendar ID (the [calendarId, instanceId] pair) + * from a principal URI and calendar URI. + * + * @param string $principalUri e.g. "principals/user@example.com" + * @param string $calendarUri e.g. "a1b2c3d4-..." + * @return array|null The calendarId pair, or null if not found. + */ + private function resolveCalendarId(string $principalUri, string $calendarUri) + { + $calendars = $this->caldavBackend->getCalendarsForUser($principalUri); + + foreach ($calendars as $calendar) { + if ($calendar['uri'] === $calendarUri) { + return $calendar['id']; + } + } + + return null; + } + + public function getPluginInfo() + { + return [ + 'name' => $this->getPluginName(), + 'description' => 'Bulk import events from a multi-event ICS file', + ]; + } +} diff --git a/env.d/development/caldav.defaults b/env.d/development/caldav.defaults index ca086e2..2bda01d 100644 --- a/env.d/development/caldav.defaults +++ b/env.d/development/caldav.defaults @@ -8,4 +8,11 @@ CALDAV_INBOUND_API_KEY=changeme-inbound-in-production CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production # Default callback URL for sending scheduling notifications (emails) # Used when clients (like Apple Calendar) don't provide X-CalDAV-Callback-URL header -CALDAV_CALLBACK_URL=http://backend-dev:8000/api/v1.0/caldav-scheduling-callback \ No newline at end of file +CALDAV_CALLBACK_URL=http://backend-dev:8000/api/v1.0/caldav-scheduling-callback +# Calendar sanitizer: strip inline binary attachments (base64 images from Outlook/Exchange) +# Applies to all CalDAV writes (client PUTs + import) +SANITIZER_STRIP_BINARY_ATTACHMENTS=true +# Calendar sanitizer: max DESCRIPTION size in bytes (0 = no limit) +SANITIZER_MAX_DESCRIPTION_BYTES=102400 +# Calendar sanitizer: max total resource size in bytes per calendar object (0 = no limit) +SANITIZER_MAX_RESOURCE_SIZE=1048576 \ No newline at end of file diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 662fb1e..6891131 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1,8 +1,5 @@ """Client serializers for the calendars core app.""" -import json -from datetime import timedelta - from django.conf import settings from django.db.models import Q from django.utils.translation import gettext_lazy as _ @@ -130,10 +127,17 @@ class CalendarSerializer(serializers.ModelSerializer): "description", "is_default", "is_visible", + "caldav_path", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "is_default", + "caldav_path", "created_at", "updated_at", ] - read_only_fields = ["id", "is_default", "created_at", "updated_at"] class CalendarCreateSerializer(serializers.ModelSerializer): @@ -198,7 +202,7 @@ class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer): return url -class CalendarSubscriptionTokenCreateSerializer(serializers.Serializer): +class CalendarSubscriptionTokenCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method """Serializer for creating a CalendarSubscriptionToken.""" caldav_path = serializers.CharField(max_length=512) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index c6b169a..38cbd47 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -4,32 +4,24 @@ import json import logging import re -from urllib.parse import unquote, urlparse +from urllib.parse import unquote from django.conf import settings from django.core.cache import cache -from django.core.exceptions import ValidationError from django.db import models as db -from django.db import transaction -from django.urls import reverse -from django.utils.decorators import method_decorator from django.utils.text import slugify import rest_framework as drf -from corsheaders.middleware import ( - ACCESS_CONTROL_ALLOW_METHODS, - ACCESS_CONTROL_ALLOW_ORIGIN, -) -from lasuite.oidc_login.decorators import refresh_oidc_access_token -from rest_framework import filters, mixins, status, viewsets +from rest_framework import mixins, status, viewsets from rest_framework import response as drf_response from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.throttling import UserRateThrottle -from rest_framework_api_key.permissions import HasAPIKey -from core import enums, models +from core import models from core.services.caldav_service import CalendarService +from core.services.import_service import MAX_FILE_SIZE, ICSImportService from . import permissions, serializers @@ -295,12 +287,14 @@ class CalendarViewSet( def get_queryset(self): """Return calendars owned by or shared with the current user.""" user = self.request.user - owned = models.Calendar.objects.filter(owner=user) shared_ids = models.CalendarShare.objects.filter(shared_with=user).values_list( "calendar_id", flat=True ) - shared = models.Calendar.objects.filter(id__in=shared_ids) - return owned.union(shared).order_by("-is_default", "name") + return ( + models.Calendar.objects.filter(db.Q(owner=user) | db.Q(id__in=shared_ids)) + .distinct() + .order_by("-is_default", "name") + ) def get_serializer_class(self): if self.action == "create": @@ -327,7 +321,7 @@ class CalendarViewSet( instance.delete() @action(detail=True, methods=["patch"]) - def toggle_visibility(self, request, pk=None): + def toggle_visibility(self, request, **kwargs): """Toggle calendar visibility.""" calendar = self.get_object() @@ -356,7 +350,7 @@ class CalendarViewSet( methods=["post"], serializer_class=serializers.CalendarShareSerializer, ) - def share(self, request, pk=None): + def share(self, request, **kwargs): """Share calendar with another user.""" calendar = self.get_object() @@ -396,6 +390,55 @@ class CalendarViewSet( status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, ) + @action( + detail=True, + methods=["post"], + parser_classes=[MultiPartParser], + url_path="import_events", + url_name="import-events", + ) + def import_events(self, request, **kwargs): + """Import events from an ICS file into this calendar.""" + calendar = self.get_object() + + # Only the owner can import events + if calendar.owner != request.user: + return drf_response.Response( + {"error": "Only the owner can import events"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Validate file presence + if "file" not in request.FILES: + return drf_response.Response( + {"error": "No file provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + uploaded_file = request.FILES["file"] + + # Validate file size + if uploaded_file.size > MAX_FILE_SIZE: + return drf_response.Response( + {"error": "File too large. Maximum size is 10 MB."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ics_data = uploaded_file.read() + service = ICSImportService() + result = service.import_events(request.user, calendar, ics_data) + + response_data = { + "total_events": result.total_events, + "imported_count": result.imported_count, + "duplicate_count": result.duplicate_count, + "skipped_count": result.skipped_count, + } + if result.errors: + response_data["errors"] = result.errors + + return drf_response.Response(response_data, status=status.HTTP_200_OK) + class SubscriptionTokenViewSet(viewsets.GenericViewSet): """ @@ -535,6 +578,7 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet): if request.method == "GET": serializer = self.get_serializer(token, context={"request": request}) return drf_response.Response(serializer.data) - elif request.method == "DELETE": - token.delete() - return drf_response.Response(status=status.HTTP_204_NO_CONTENT) + + # DELETE + token.delete() + return drf_response.Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/backend/core/api/viewsets_caldav.py b/src/backend/core/api/viewsets_caldav.py index c33225a..3f31e1b 100644 --- a/src/backend/core/api/viewsets_caldav.py +++ b/src/backend/core/api/viewsets_caldav.py @@ -28,7 +28,7 @@ class CalDAVProxyView(View): Authentication is handled via session cookies instead. """ - def dispatch(self, request, *args, **kwargs): + def dispatch(self, request, *args, **kwargs): # noqa: PLR0912 # pylint: disable=too-many-branches """Forward all HTTP methods to CalDAV server.""" # Handle CORS preflight requests if request.method == "OPTIONS": @@ -247,7 +247,8 @@ class CalDAVSchedulingCallbackView(View): ) return HttpResponse( status=400, - content="Missing required headers: X-CalDAV-Sender, X-CalDAV-Recipient, X-CalDAV-Method", + content="Missing required headers: X-CalDAV-Sender, " + "X-CalDAV-Recipient, X-CalDAV-Method", content_type="text/plain", ) @@ -291,20 +292,20 @@ class CalDAVSchedulingCallbackView(View): content="OK", content_type="text/plain", ) - else: - logger.error( - "Failed to send calendar %s email: %s -> %s", - method, - sender, - recipient, - ) - return HttpResponse( - status=500, - content="Failed to send email", - content_type="text/plain", - ) - except Exception as e: + logger.error( + "Failed to send calendar %s email: %s -> %s", + method, + sender, + recipient, + ) + return HttpResponse( + status=500, + content="Failed to send email", + content_type="text/plain", + ) + + except Exception as e: # pylint: disable=broad-exception-caught logger.exception("Error processing CalDAV scheduling callback: %s", e) return HttpResponse( status=500, diff --git a/src/backend/core/services/caldav_service.py b/src/backend/core/services/caldav_service.py index bbfa150..5627cb8 100644 --- a/src/backend/core/services/caldav_service.py +++ b/src/backend/core/services/caldav_service.py @@ -99,7 +99,7 @@ class CalDAVClient: except NotFoundError: logger.warning("Calendar not found at path: %s", calendar_path) return None - except Exception as e: + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught logger.error("Failed to get calendar info from CalDAV: %s", str(e)) return None @@ -186,6 +186,25 @@ class CalDAVClient: logger.error("Failed to get events from CalDAV server: %s", str(e)) raise + def create_event_raw(self, user, calendar_path: str, ics_data: str) -> str: + """ + Create an event in CalDAV server from raw ICS data. + The ics_data should be a complete VCALENDAR string. + Returns the event UID. + """ + client = self._get_client(user) + calendar_url = f"{self.base_url}{calendar_path}" + calendar = client.calendar(url=calendar_url) + + try: + event = calendar.save_event(ics_data) + event_uid = str(event.icalendar_component.get("uid", "")) + logger.info("Created event in CalDAV server: %s", event_uid) + return event_uid + except Exception as e: + logger.error("Failed to create event in CalDAV server: %s", str(e)) + raise + def create_event(self, user, calendar_path: str, event_data: dict) -> str: """ Create a new event in CalDAV server. @@ -353,7 +372,7 @@ class CalDAVClient: event_data["end"] = event_data["end"].strftime("%Y%m%d") return event_data if event_data.get("uid") else None - except Exception as e: + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught logger.warning("Failed to parse event: %s", str(e)) return None diff --git a/src/backend/core/services/calendar_invitation_service.py b/src/backend/core/services/calendar_invitation_service.py index 7cde276..c8df919 100644 --- a/src/backend/core/services/calendar_invitation_service.py +++ b/src/backend/core/services/calendar_invitation_service.py @@ -17,6 +17,10 @@ from email import encoders from email.mime.base import MIMEBase from typing import Optional +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string + # French month and day names for date formatting FRENCH_DAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"] FRENCH_MONTHS = [ @@ -35,15 +39,11 @@ FRENCH_MONTHS = [ "décembre", ] -from django.conf import settings -from django.core.mail import EmailMultiAlternatives -from django.template.loader import render_to_string - logger = logging.getLogger(__name__) @dataclass -class EventDetails: +class EventDetails: # pylint: disable=too-many-instance-attributes """Parsed event details from iCalendar data.""" uid: str @@ -127,10 +127,9 @@ class ICalendarParser: if params_str: # Split by ; but not within quotes param_matches = re.findall(r";([^=]+)=([^;]+)", params_str) - for param_name, param_value in param_matches: + for param_name, raw_value in param_matches: # Remove quotes if present - param_value = param_value.strip('"') - params[param_name.upper()] = param_value + params[param_name.upper()] = raw_value.strip('"') return value, params @@ -160,13 +159,17 @@ class ICalendarParser: elif tzid: # Has timezone info - try to convert using zoneinfo try: - from zoneinfo import ZoneInfo + from zoneinfo import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel + ZoneInfo, + ) tz = ZoneInfo(tzid) dt = dt.replace(tzinfo=tz) - except Exception: + except (KeyError, ValueError): # If timezone conversion fails, keep as naive datetime - pass + logger.debug( + "Unknown timezone %s, keeping naive datetime", tzid + ) return dt except ValueError: continue @@ -175,7 +178,9 @@ class ICalendarParser: return None @classmethod - def parse(cls, icalendar: str, recipient_email: str) -> Optional[EventDetails]: + def parse( # pylint: disable=too-many-locals,too-many-branches + cls, icalendar: str, recipient_email: str + ) -> Optional[EventDetails]: """ Parse iCalendar data and extract event details. @@ -272,12 +277,12 @@ class ICalendarParser: raw_icalendar=icalendar, ) - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught logger.exception("Failed to parse iCalendar data: %s", e) return None -class CalendarInvitationService: +class CalendarInvitationService: # pylint: disable=too-many-instance-attributes """ Service for sending calendar invitation emails. @@ -369,7 +374,7 @@ class CalendarInvitationService: event_uid=event.uid, ) - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught logger.exception( "Failed to send calendar invitation to %s: %s", recipient, e ) @@ -456,7 +461,7 @@ class CalendarInvitationService: return icalendar_data - def _send_email( + def _send_email( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments self, from_email: str, to_email: str, @@ -506,7 +511,7 @@ class CalendarInvitationService: "Content-Type", f"text/calendar; charset=utf-8; method={ics_method}" ) ics_attachment.add_header( - "Content-Disposition", f'attachment; filename="invite.ics"' + "Content-Disposition", 'attachment; filename="invite.ics"' ) # Attach the ICS file @@ -524,7 +529,7 @@ class CalendarInvitationService: ) return True - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught logger.exception( "Failed to send calendar invitation email to %s: %s", to_email, e ) diff --git a/src/backend/core/services/import_service.py b/src/backend/core/services/import_service.py new file mode 100644 index 0000000..9e6bc1b --- /dev/null +++ b/src/backend/core/services/import_service.py @@ -0,0 +1,115 @@ +"""Service for importing events from ICS files.""" + +import logging +from dataclasses import dataclass, field + +from django.conf import settings + +import requests + +logger = logging.getLogger(__name__) + +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB + + +@dataclass +class ImportResult: + """Result of an ICS import operation. + + errors contains event names (summaries) of failed events, + at most 10 entries. + """ + + total_events: int = 0 + imported_count: int = 0 + duplicate_count: int = 0 + skipped_count: int = 0 + errors: list[str] = field(default_factory=list) + + +class ICSImportService: + """Service for importing events from ICS data into a CalDAV calendar. + + Sends the raw ICS file in a single POST to the SabreDAV ICS import + plugin which handles splitting, validation/repair, and direct DB + insertion. + """ + + def __init__(self): + self.base_url = settings.CALDAV_URL.rstrip("/") + + def import_events(self, user, calendar, ics_data: bytes) -> ImportResult: + """Import events from ICS data into a calendar. + + Sends the raw ICS bytes to SabreDAV's ?import endpoint which + handles all ICS parsing, splitting by UID, VALARM repair, and + per-event insertion. + """ + result = ImportResult() + + # caldav_path already includes the base URI prefix + # e.g. /api/v1.0/caldav/calendars/user@example.com/uuid/ + url = f"{self.base_url}{calendar.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") + return result + + headers = { + "Content-Type": "text/calendar", + "X-Api-Key": outbound_api_key, + "X-Forwarded-User": user.email, + "X-Calendars-Import": outbound_api_key, + } + + try: + # Timeout scales with file size: 60s base + 30s per MB of ICS data. + # 8000 events (~4MB) took ~70s in practice. + timeout = 60 + int(len(ics_data) / 1024 / 1024) * 30 + response = requests.post( + url, data=ics_data, headers=headers, timeout=timeout + ) + except requests.RequestException as exc: + logger.error("Failed to reach SabreDAV import endpoint: %s", exc) + result.errors.append("Failed to reach CalDAV server") + return result + + if response.status_code != 200: + logger.error( + "SabreDAV import returned %s: %s", + response.status_code, + response.text[:500], + ) + result.errors.append("CalDAV server error") + return result + + try: + data = response.json() + except ValueError: + logger.error("Invalid JSON from SabreDAV import: %s", response.text[:500]) + result.errors.append("Invalid response from CalDAV server") + return result + + result.total_events = data.get("total_events", 0) + result.imported_count = data.get("imported_count", 0) + result.duplicate_count = data.get("duplicate_count", 0) + result.skipped_count = data.get("skipped_count", 0) + + # SabreDAV returns structured errors {uid, summary, error}. + # Log full details server-side, expose only event names to the frontend. + for err in data.get("errors", []): + if isinstance(err, dict): + logger.warning( + "Import failed for uid=%s summary=%s: %s", + err.get("uid", "?"), + err.get("summary", "?"), + err.get("error", "?"), + ) + result.errors.append( + err.get("summary") or err.get("uid", "Unknown event") + ) + else: + result.errors.append(str(err)) + + return result diff --git a/src/backend/core/signals.py b/src/backend/core/signals.py index aebd1d6..e65efbc 100644 --- a/src/backend/core/signals.py +++ b/src/backend/core/signals.py @@ -16,7 +16,7 @@ User = get_user_model() @receiver(post_save, sender=User) -def provision_default_calendar(sender, instance, created, **kwargs): +def provision_default_calendar(sender, instance, created, **kwargs): # pylint: disable=unused-argument """ Auto-provision a default calendar when a new user is created. """ @@ -35,7 +35,7 @@ def provision_default_calendar(sender, instance, created, **kwargs): service = CalendarService() service.create_default_calendar(instance) logger.info("Created default calendar for user %s", instance.email) - except Exception as e: + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught # In tests, CalDAV server may not be available, so fail silently # Check if it's a database error that suggests we're in tests error_str = str(e).lower() diff --git a/src/backend/core/tests/test_caldav_scheduling.py b/src/backend/core/tests/test_caldav_scheduling.py index c22eeb0..cf0e34c 100644 --- a/src/backend/core/tests/test_caldav_scheduling.py +++ b/src/backend/core/tests/test_caldav_scheduling.py @@ -26,7 +26,7 @@ class CallbackHandler(http.server.BaseHTTPRequestHandler): self.callback_data = callback_data super().__init__(*args, **kwargs) - def do_POST(self): + def do_POST(self): # pylint: disable=invalid-name """Handle POST requests (scheduling callbacks).""" content_length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(content_length) if content_length > 0 else b"" @@ -44,9 +44,8 @@ class CallbackHandler(http.server.BaseHTTPRequestHandler): self.end_headers() self.wfile.write(b"OK") - def log_message(self, format, *args): + def log_message(self, format, *args): # pylint: disable=redefined-builtin """Suppress default logging.""" - pass def create_test_server() -> tuple: @@ -79,7 +78,9 @@ class TestCalDAVScheduling: not settings.CALDAV_URL, reason="CalDAV server URL not configured - integration test requires real server", ) - def test_scheduling_callback_received_when_creating_event_with_attendee(self): + def test_scheduling_callback_received_when_creating_event_with_attendee( # noqa: PLR0915 # pylint: disable=too-many-locals,too-many-statements + self, + ): """Test that creating an event with an attendee triggers scheduling callback. This test verifies that when an event is created with an attendee via CalDAV, @@ -114,7 +115,7 @@ class TestCalDAVScheduling: try: test_socket.connect(("127.0.0.1", port)) test_socket.close() - except Exception as e: + except OSError as e: pytest.fail(f"Test server failed to start on port {port}: {e}") # Use the named test container hostname @@ -124,7 +125,7 @@ class TestCalDAVScheduling: try: # Create an event with an attendee - client = service.caldav._get_client(organizer) + client = service.caldav._get_client(organizer) # pylint: disable=protected-access calendar_url = f"{settings.CALDAV_URL}{calendar.caldav_path}" # Add custom callback URL header to the client @@ -159,7 +160,7 @@ END:VEVENT END:VCALENDAR""" # Save event to trigger scheduling - event = caldav_calendar.save_event(ical_content) + caldav_calendar.save_event(ical_content) # Give the callback a moment to be called (scheduling may be async) # sabre/dav processes scheduling synchronously during the request @@ -167,21 +168,24 @@ END:VCALENDAR""" # Verify callback was called assert callback_data["called"], ( - "Scheduling callback was not called when creating event with attendee. " - "This may indicate that sabre/dav's scheduling plugin is not working correctly. " + "Scheduling callback was not called when creating event " + "with attendee. This may indicate that sabre/dav's " + "scheduling plugin is not working correctly. " "Check CalDAV server logs for scheduling errors." ) # Verify callback request details - request_data = callback_data["request_data"] + # pylint: disable=unsubscriptable-object + request_data: dict = callback_data["request_data"] assert request_data is not None # Verify API key authentication api_key = request_data["headers"].get("X-Api-Key", "") expected_key = settings.CALDAV_INBOUND_API_KEY assert expected_key and secrets.compare_digest(api_key, expected_key), ( - f"Callback request missing or invalid X-Api-Key header. " - f"Expected: {expected_key[:10]}..., Got: {api_key[:10] if api_key else 'None'}..." + "Callback request missing or invalid X-Api-Key header. " + f"Expected: {expected_key[:10]}..., " + f"Got: {api_key[:10] if api_key else 'None'}..." ) # Verify scheduling headers @@ -238,7 +242,7 @@ END:VCALENDAR""" except NotFoundError: pytest.skip("Calendar not found - CalDAV server may not be running") - except Exception as e: + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught pytest.fail(f"Failed to create event with attendee: {str(e)}") finally: # Shutdown server diff --git a/src/backend/core/tests/test_import_events.py b/src/backend/core/tests/test_import_events.py new file mode 100644 index 0000000..bda6965 --- /dev/null +++ b/src/backend/core/tests/test_import_events.py @@ -0,0 +1,1088 @@ +"""Tests for the ICS import events feature.""" # pylint: disable=too-many-lines + +import json +from datetime import datetime +from datetime import timezone as dt_tz +from unittest.mock import MagicMock, patch + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile + +import pytest +import requests as req +from rest_framework.test import APIClient + +from core import factories +from core.services.caldav_service import CalDAVClient, CalendarService +from core.services.import_service import MAX_FILE_SIZE, ICSImportService, ImportResult + +pytestmark = pytest.mark.django_db + +# --- ICS test constants --- + +ICS_SINGLE_EVENT = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:single-event-123 +DTSTART:20260210T140000Z +DTEND:20260210T150000Z +SUMMARY:Team meeting +DESCRIPTION:Weekly standup +END:VEVENT +END:VCALENDAR""" + +ICS_MULTIPLE_EVENTS = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:event-1 +DTSTART:20260210T090000Z +DTEND:20260210T100000Z +SUMMARY:Morning standup +END:VEVENT +BEGIN:VEVENT +UID:event-2 +DTSTART:20260210T140000Z +DTEND:20260210T150000Z +SUMMARY:Afternoon review +END:VEVENT +BEGIN:VEVENT +UID:event-3 +DTSTART:20260211T100000Z +DTEND:20260211T110000Z +SUMMARY:Planning session +END:VEVENT +END:VCALENDAR""" + +ICS_ALL_DAY_EVENT = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:allday-event-1 +DTSTART;VALUE=DATE:20260215 +DTEND;VALUE=DATE:20260216 +SUMMARY:Company holiday +END:VEVENT +END:VCALENDAR""" + +ICS_WITH_TIMEZONE = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VTIMEZONE +TZID:Europe/Paris +BEGIN:STANDARD +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:tz-event-1 +DTSTART;TZID=Europe/Paris:20260210T140000 +DTEND;TZID=Europe/Paris:20260210T150000 +SUMMARY:Paris meeting +END:VEVENT +END:VCALENDAR""" + +ICS_RECURRING_EVENT = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:recurring-event-1 +DTSTART:20260210T090000Z +DTEND:20260210T100000Z +SUMMARY:Daily standup +RRULE:FREQ=DAILY;COUNT=5 +END:VEVENT +END:VCALENDAR""" + +ICS_WITH_ATTENDEES = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:attendee-event-1 +DTSTART:20260210T140000Z +DTEND:20260210T150000Z +SUMMARY:Review meeting +ORGANIZER;CN=Alice:mailto:alice@example.com +ATTENDEE;CN=Bob;RSVP=TRUE:mailto:bob@example.com +END:VEVENT +END:VCALENDAR""" + +ICS_WITH_NEWLINES_IN_DESCRIPTION = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:newline-desc-1 +DTSTART:20260210T140000Z +DTEND:20260210T150000Z +SUMMARY:Meeting with notes +DESCRIPTION:Line one\\nLine two\\nLine three\\, with comma +END:VEVENT +END:VCALENDAR""" + +ICS_EMPTY = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +END:VCALENDAR""" + +ICS_INVALID = b"This is not valid ICS data" + +ICS_VALARM_NO_ACTION = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:valarm-no-action-1 +DTSTART:20260210T140000Z +DTEND:20260210T150000Z +SUMMARY:Event with broken alarm +BEGIN:VALARM +TRIGGER:-PT15M +DESCRIPTION:Reminder +END:VALARM +END:VEVENT +END:VCALENDAR""" + +ICS_RECURRING_WITH_EXCEPTION = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:recurring-exc-1 +DTSTART:20260210T090000Z +DTEND:20260210T100000Z +SUMMARY:Weekly sync +RRULE:FREQ=WEEKLY;COUNT=4 +END:VEVENT +BEGIN:VEVENT +UID:recurring-exc-1 +RECURRENCE-ID:20260217T090000Z +DTSTART:20260217T100000Z +DTEND:20260217T110000Z +SUMMARY:Weekly sync (moved) +END:VEVENT +END:VCALENDAR""" + +ICS_DEAD_RECURRING = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +DTSTART:20191024T170000Z +DTEND:20191024T180000Z +RRULE:FREQ=WEEKLY;UNTIL=20191106T225959Z;INTERVAL=2 +EXDATE:20191024T170000Z +SUMMARY:Dead recurring event +UID:dead-recurring-1 +END:VEVENT +END:VCALENDAR""" + +ICS_WITH_BINARY_ATTACHMENT = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:attach-binary-1 +DTSTART:20260210T140000Z +DTEND:20260210T150000Z +SUMMARY:Event with inline attachment +ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE=image/png:iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg== +END:VEVENT +END:VCALENDAR""" + +ICS_WITH_URL_ATTACHMENT = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:attach-url-1 +DTSTART:20260210T140000Z +DTEND:20260210T150000Z +SUMMARY:Event with URL attachment +ATTACH;FMTTYPE=application/pdf:https://example.com/doc.pdf +END:VEVENT +END:VCALENDAR""" + +# Generate a large description (200KB) for truncation testing +_LARGE_DESC = "A" * 200000 +ICS_WITH_LARGE_DESCRIPTION = ( + b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n" + b"BEGIN:VEVENT\r\nUID:large-desc-1\r\n" + b"DTSTART:20260210T140000Z\r\nDTEND:20260210T150000Z\r\n" + b"SUMMARY:Event with huge description\r\nDESCRIPTION:" + + _LARGE_DESC.encode() + + b"\r\nEND:VEVENT\r\nEND:VCALENDAR" +) + +# Generate an ICS that exceeds 1MB via many ATTENDEE lines (not stripped by sanitizer) +_OVERSIZED_ATTENDEES = "\r\n".join( + f"ATTENDEE;CN=User {i}:mailto:user{i}@example-long-domain-padding-{i:06d}.com" + for i in range(15000) +) +ICS_OVERSIZED_EVENT = ( + b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n" + b"BEGIN:VEVENT\r\nUID:oversized-event-1\r\n" + b"DTSTART:20260210T140000Z\r\nDTEND:20260210T150000Z\r\n" + b"SUMMARY:Oversized event\r\n" + + _OVERSIZED_ATTENDEES.encode() + + b"\r\nEND:VEVENT\r\nEND:VCALENDAR" +) + +ICS_NO_DTSTART = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:no-start-event +SUMMARY:Missing start +END:VEVENT +END:VCALENDAR""" + + +def _make_sabredav_response( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments + status_code=200, + total_events=0, + imported_count=0, + duplicate_count=0, + skipped_count=0, + errors=None, +): + """Build a mock requests.Response mimicking SabreDAV import plugin.""" + mock_resp = MagicMock() + mock_resp.status_code = status_code + body = { + "total_events": total_events, + "imported_count": imported_count, + "duplicate_count": duplicate_count, + "skipped_count": skipped_count, + "errors": errors or [], + } + mock_resp.json.return_value = body + mock_resp.text = json.dumps(body) + return mock_resp + + +class TestICSImportService: + """Unit tests for ICSImportService with mocked HTTP call to SabreDAV.""" + + @patch("core.services.import_service.requests.post") + def test_import_single_event(self, mock_post): + """Importing a single event should succeed.""" + mock_post.return_value = _make_sabredav_response( + total_events=1, imported_count=1 + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_SINGLE_EVENT) + + assert result.total_events == 1 + assert result.imported_count == 1 + assert result.skipped_count == 0 + assert not result.errors + mock_post.assert_called_once() + + # Verify the raw ICS was sent as-is + call_kwargs = mock_post.call_args + assert call_kwargs.kwargs["data"] == ICS_SINGLE_EVENT + + @patch("core.services.import_service.requests.post") + def test_import_multiple_events(self, mock_post): + """Importing multiple events should forward all to SabreDAV.""" + mock_post.return_value = _make_sabredav_response( + total_events=3, imported_count=3 + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) + + assert result.total_events == 3 + assert result.imported_count == 3 + assert result.skipped_count == 0 + assert not result.errors + # Single HTTP call, not one per event + mock_post.assert_called_once() + + @patch("core.services.import_service.requests.post") + def test_import_empty_ics(self, mock_post): + """Importing an ICS with no events should return zero counts.""" + mock_post.return_value = _make_sabredav_response( + total_events=0, imported_count=0 + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_EMPTY) + + assert result.total_events == 0 + assert result.imported_count == 0 + assert result.skipped_count == 0 + assert not result.errors + + @patch("core.services.import_service.requests.post") + def test_import_invalid_ics(self, mock_post): + """Importing invalid ICS data should return an error from SabreDAV.""" + mock_post.return_value = _make_sabredav_response( + status_code=400, + ) + mock_post.return_value.text = '{"error": "Failed to parse ICS file"}' + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_INVALID) + + assert result.imported_count == 0 + assert len(result.errors) >= 1 + + @patch("core.services.import_service.requests.post") + def test_import_with_timezone(self, mock_post): + """Events with timezones should be forwarded to SabreDAV.""" + mock_post.return_value = _make_sabredav_response( + total_events=1, imported_count=1 + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_WITH_TIMEZONE) + + assert result.total_events == 1 + assert result.imported_count == 1 + + # Verify the raw ICS was sent as-is (timezone included) + call_kwargs = mock_post.call_args + assert b"VTIMEZONE" in call_kwargs.kwargs["data"] + assert b"Europe/Paris" in call_kwargs.kwargs["data"] + + @patch("core.services.import_service.requests.post") + def test_import_partial_failure(self, mock_post): + """When some events fail, SabreDAV reports partial success.""" + mock_post.return_value = _make_sabredav_response( + total_events=3, + imported_count=2, + skipped_count=1, + errors=[ + { + "uid": "event-2", + "summary": "Afternoon review", + "error": "Some CalDAV error", + } + ], + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) + + assert result.total_events == 3 + assert result.imported_count == 2 + assert result.skipped_count == 1 + assert len(result.errors) == 1 + # Only event name is exposed, not raw error details + assert result.errors[0] == "Afternoon review" + + @patch("core.services.import_service.requests.post") + def test_import_all_day_event(self, mock_post): + """All-day events should be forwarded to SabreDAV.""" + mock_post.return_value = _make_sabredav_response( + total_events=1, imported_count=1 + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_ALL_DAY_EVENT) + + assert result.total_events == 1 + assert result.imported_count == 1 + + @patch("core.services.import_service.requests.post") + def test_import_valarm_without_action(self, mock_post): + """VALARM without ACTION is handled by SabreDAV plugin repair.""" + mock_post.return_value = _make_sabredav_response( + total_events=1, imported_count=1 + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_VALARM_NO_ACTION) + + assert result.total_events == 1 + assert result.imported_count == 1 + + @patch("core.services.import_service.requests.post") + def test_import_recurring_with_exception(self, mock_post): + """Recurring event + modified occurrence handled by SabreDAV splitter.""" + mock_post.return_value = _make_sabredav_response( + total_events=1, imported_count=1 + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_RECURRING_WITH_EXCEPTION) + + # Two VEVENTs with same UID = one logical event + assert result.total_events == 1 + assert result.imported_count == 1 + + @patch("core.services.import_service.requests.post") + def test_import_event_missing_dtstart(self, mock_post): + """Events without DTSTART handling is delegated to SabreDAV.""" + mock_post.return_value = _make_sabredav_response( + total_events=1, + imported_count=0, + skipped_count=1, + errors=[ + { + "uid": "no-start-event", + "summary": "Missing start", + "error": "DTSTART is required", + } + ], + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_NO_DTSTART) + + assert result.total_events == 1 + assert result.imported_count == 0 + assert result.skipped_count == 1 + assert result.errors[0] == "Missing start" + + @patch("core.services.import_service.requests.post") + def test_import_passes_calendar_path(self, mock_post): + """The import URL should include the calendar's caldav_path.""" + mock_post.return_value = _make_sabredav_response( + total_events=1, imported_count=1 + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + service.import_events(user, calendar, ICS_SINGLE_EVENT) + + call_args = mock_post.call_args + url = call_args.args[0] if call_args.args else call_args.kwargs.get("url", "") + assert calendar.caldav_path in url + assert "?import" in url + + @patch("core.services.import_service.requests.post") + def test_import_sends_auth_headers(self, mock_post): + """The import request must include all required auth headers.""" + mock_post.return_value = _make_sabredav_response( + total_events=1, imported_count=1 + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + service.import_events(user, calendar, ICS_SINGLE_EVENT) + + call_kwargs = mock_post.call_args.kwargs + headers = call_kwargs["headers"] + assert headers["X-Api-Key"] == settings.CALDAV_OUTBOUND_API_KEY + assert headers["X-Forwarded-User"] == user.email + assert headers["X-Calendars-Import"] == settings.CALDAV_OUTBOUND_API_KEY + assert headers["Content-Type"] == "text/calendar" + + @patch("core.services.import_service.requests.post") + def test_import_duplicates_not_treated_as_errors(self, mock_post): + """Duplicate events should be counted separately, not as errors.""" + mock_post.return_value = _make_sabredav_response( + total_events=3, + imported_count=1, + duplicate_count=2, + skipped_count=0, + errors=[], + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) + + assert result.total_events == 3 + assert result.imported_count == 1 + assert result.duplicate_count == 2 + assert result.skipped_count == 0 + assert not result.errors + + @patch("core.services.import_service.requests.post") + def test_import_network_failure(self, mock_post): + """Network failures should return a graceful error.""" + mock_post.side_effect = req.ConnectionError("Connection refused") + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + service = ICSImportService() + result = service.import_events(user, calendar, ICS_SINGLE_EVENT) + + assert result.imported_count == 0 + assert len(result.errors) >= 1 + + +class TestImportEventsAPI: + """API endpoint tests for the import_events action.""" + + def _get_url(self, calendar_id): + return f"/api/v1.0/calendars/{calendar_id}/import_events/" + + def test_import_events_requires_authentication(self): + """Unauthenticated requests should be rejected.""" + calendar = factories.CalendarFactory() + client = APIClient() + + response = client.post(self._get_url(calendar.id)) + + assert response.status_code == 401 + + def test_import_events_forbidden_for_non_owner(self): + """Non-owners should not be able to access the calendar.""" + owner = factories.UserFactory() + other_user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=owner) + + client = APIClient() + client.force_login(other_user) + + ics_file = SimpleUploadedFile( + "events.ics", ICS_SINGLE_EVENT, content_type="text/calendar" + ) + response = client.post( + self._get_url(calendar.id), {"file": ics_file}, format="multipart" + ) + + # Calendar not in queryset for non-owner, so 404 (not 403) + assert response.status_code == 404 + + def test_import_events_missing_file(self): + """Request without a file should return 400.""" + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + client = APIClient() + client.force_login(user) + + response = client.post(self._get_url(calendar.id), format="multipart") + + assert response.status_code == 400 + assert "No file provided" in response.json()["error"] + + def test_import_events_file_too_large(self): + """Files exceeding MAX_FILE_SIZE should be rejected.""" + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + client = APIClient() + client.force_login(user) + + large_file = SimpleUploadedFile( + "events.ics", + b"x" * (MAX_FILE_SIZE + 1), + content_type="text/calendar", + ) + response = client.post( + self._get_url(calendar.id), {"file": large_file}, format="multipart" + ) + + assert response.status_code == 400 + assert "too large" in response.json()["error"] + + @patch.object(ICSImportService, "import_events") + def test_import_events_success(self, mock_import): + """Successful import should return result data.""" + mock_import.return_value = ImportResult( + total_events=3, + imported_count=3, + duplicate_count=0, + skipped_count=0, + errors=[], + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + client = APIClient() + client.force_login(user) + + ics_file = SimpleUploadedFile( + "events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar" + ) + response = client.post( + self._get_url(calendar.id), {"file": ics_file}, format="multipart" + ) + + assert response.status_code == 200 + data = response.json() + assert data["total_events"] == 3 + assert data["imported_count"] == 3 + assert data["skipped_count"] == 0 + assert "errors" not in data + + @patch.object(ICSImportService, "import_events") + def test_import_events_partial_success(self, mock_import): + """Partial success should include errors in response.""" + mock_import.return_value = ImportResult( + total_events=3, + imported_count=2, + duplicate_count=0, + skipped_count=1, + errors=["Planning session"], + ) + + user = factories.UserFactory() + calendar = factories.CalendarFactory(owner=user) + + client = APIClient() + client.force_login(user) + + ics_file = SimpleUploadedFile( + "events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar" + ) + response = client.post( + self._get_url(calendar.id), {"file": ics_file}, format="multipart" + ) + + assert response.status_code == 200 + data = response.json() + assert data["total_events"] == 3 + assert data["imported_count"] == 2 + assert data["skipped_count"] == 1 + assert len(data["errors"]) == 1 + + +@pytest.mark.skipif( + not settings.CALDAV_URL, + reason="CalDAV server URL not configured", +) +class TestImportEventsE2E: + """End-to-end tests that import ICS events through the real SabreDAV server.""" + + def _create_calendar(self, user): + """Create a real calendar in both Django and SabreDAV.""" + service = CalendarService() + return service.create_calendar(user, name="Import Test", color="#3174ad") + + def test_import_single_event_e2e(self): + """Import a single event and verify it exists in SabreDAV.""" + user = factories.UserFactory(email="import-single@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + result = import_service.import_events(user, calendar, ICS_SINGLE_EVENT) + + assert result.total_events == 1 + assert result.imported_count == 1 + assert result.skipped_count == 0 + assert not result.errors + + # Verify the event actually exists in SabreDAV + caldav = CalDAVClient() + events = caldav.get_events( + user, + calendar.caldav_path, + start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), + end=datetime(2026, 2, 11, tzinfo=dt_tz.utc), + ) + assert len(events) == 1 + assert events[0]["title"] == "Team meeting" + assert events[0]["uid"] == "single-event-123" + + def test_import_multiple_events_e2e(self): + """Import multiple events and verify they all exist in SabreDAV.""" + user = factories.UserFactory(email="import-multi@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + result = import_service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) + + assert result.total_events == 3 + assert result.imported_count == 3 + assert not result.errors + + # Verify all events exist in SabreDAV + caldav = CalDAVClient() + events = caldav.get_events( + user, + calendar.caldav_path, + start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), + end=datetime(2026, 2, 12, tzinfo=dt_tz.utc), + ) + assert len(events) == 3 + titles = {e["title"] for e in events} + assert titles == {"Morning standup", "Afternoon review", "Planning session"} + + def test_import_all_day_event_e2e(self): + """Import an all-day event and verify it exists in SabreDAV.""" + user = factories.UserFactory(email="import-allday@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + result = import_service.import_events(user, calendar, ICS_ALL_DAY_EVENT) + + assert result.total_events == 1 + assert result.imported_count == 1 + assert not result.errors + + # Verify the event exists in SabreDAV + caldav = CalDAVClient() + events = caldav.get_events( + user, + calendar.caldav_path, + start=datetime(2026, 2, 14, tzinfo=dt_tz.utc), + end=datetime(2026, 2, 17, tzinfo=dt_tz.utc), + ) + assert len(events) == 1 + assert events[0]["title"] == "Company holiday" + + def test_import_with_timezone_e2e(self): + """Import an event with timezone info and verify it in SabreDAV.""" + user = factories.UserFactory(email="import-tz@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + result = import_service.import_events(user, calendar, ICS_WITH_TIMEZONE) + + assert result.total_events == 1 + assert result.imported_count == 1 + assert not result.errors + + # Verify the event exists in SabreDAV + caldav = CalDAVClient() + events = caldav.get_events( + user, + calendar.caldav_path, + start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), + end=datetime(2026, 2, 11, tzinfo=dt_tz.utc), + ) + assert len(events) == 1 + assert events[0]["title"] == "Paris meeting" + + def test_import_via_api_e2e(self): + """Import events via the API endpoint hitting real SabreDAV.""" + user = factories.UserFactory(email="import-api@example.com") + calendar = self._create_calendar(user) + + client = APIClient() + client.force_login(user) + + ics_file = SimpleUploadedFile( + "events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar" + ) + response = client.post( + f"/api/v1.0/calendars/{calendar.id}/import_events/", + {"file": ics_file}, + format="multipart", + ) + + assert response.status_code == 200 + data = response.json() + assert data["total_events"] == 3 + assert data["imported_count"] == 3 + assert data["skipped_count"] == 0 + + # Verify events actually exist in SabreDAV + caldav = CalDAVClient() + events = caldav.get_events( + user, + calendar.caldav_path, + start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), + end=datetime(2026, 2, 12, tzinfo=dt_tz.utc), + ) + assert len(events) == 3 + + def test_import_event_with_attendees_then_update_e2e(self): + """Import an event with attendees and update it. + + This exercises the SabreDAV beforeWriteContent codepath in the + AttendeeNormalizerPlugin, which previously failed because the + plugin used the wrong callback signature for that event. + """ + user = factories.UserFactory(email="import-attendee@example.com") + calendar = self._create_calendar(user) + + # Import event with attendees + import_service = ICSImportService() + result = import_service.import_events(user, calendar, ICS_WITH_ATTENDEES) + + assert result.total_events == 1 + assert result.imported_count == 1 + assert not result.errors + + # Update the same event — triggers beforeWriteContent in SabreDAV + caldav = CalDAVClient() + caldav.update_event( + user, + calendar.caldav_path, + "attendee-event-1", + {"title": "Updated review meeting"}, + ) + + # Verify update was applied + events = caldav.get_events( + user, + calendar.caldav_path, + start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), + end=datetime(2026, 2, 11, tzinfo=dt_tz.utc), + ) + assert len(events) == 1 + assert events[0]["title"] == "Updated review meeting" + + def test_import_event_with_ics_escapes_e2e(self): + """Import event whose description contains ICS escapes (\\n, \\,). + + These backslash sequences in ICS data can cause PostgreSQL bytea + parse errors if the calendardata column is bytea and SabreDAV + binds values as PARAM_STR instead of PARAM_LOB. + """ + user = factories.UserFactory(email="import-escapes@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + result = import_service.import_events( + user, calendar, ICS_WITH_NEWLINES_IN_DESCRIPTION + ) + + assert result.total_events == 1 + assert result.imported_count == 1 + assert not result.errors + + # Verify event exists in SabreDAV + caldav = CalDAVClient() + events = caldav.get_events( + user, + calendar.caldav_path, + start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), + end=datetime(2026, 2, 11, tzinfo=dt_tz.utc), + ) + assert len(events) == 1 + assert events[0]["title"] == "Meeting with notes" + + def test_import_same_file_twice_no_duplicates_e2e(self): + """Importing the same ICS file twice should not create duplicates.""" + user = factories.UserFactory(email="import-dedup@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + + # First import + result1 = import_service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) + assert result1.imported_count == 3 + assert not result1.errors + + # Second import of the same file — all should be duplicates + result2 = import_service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) + assert result2.duplicate_count == 3 + assert result2.imported_count == 0 + assert result2.skipped_count == 0 + + # Verify no duplicates in SabreDAV + caldav = CalDAVClient() + events = caldav.get_events( + user, + calendar.caldav_path, + start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), + end=datetime(2026, 2, 12, tzinfo=dt_tz.utc), + ) + assert len(events) == 3 + + def test_import_dead_recurring_event_skipped_silently_e2e(self): + """A recurring event whose EXDATE excludes all instances is skipped, not an error.""" + user = factories.UserFactory(email="import-dead-recur@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + result = import_service.import_events(user, calendar, ICS_DEAD_RECURRING) + + assert result.total_events == 1 + assert result.imported_count == 0 + assert result.skipped_count == 1 + assert not result.errors + + def _get_raw_event(self, user, calendar, uid): + """Fetch the raw ICS data of a single event from SabreDAV by UID.""" + caldav_client = CalDAVClient() + client = caldav_client._get_client(user) # pylint: disable=protected-access + cal_url = f"{caldav_client.base_url}{calendar.caldav_path}" + cal = client.calendar(url=cal_url) + event = cal.event_by_uid(uid) + return event.data + + def test_import_strips_binary_attachments_e2e(self): + """Binary attachments should be stripped during import.""" + user = factories.UserFactory(email="import-strip-attach@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + result = import_service.import_events( + user, calendar, ICS_WITH_BINARY_ATTACHMENT + ) + + assert result.total_events == 1 + assert result.imported_count == 1 + assert not result.errors + + # Verify event exists and binary attachment was stripped + raw = self._get_raw_event(user, calendar, "attach-binary-1") + assert "Event with inline attachment" in raw + assert "iVBORw0KGgo" not in raw + assert "ATTACH" not in raw + + def test_import_keeps_url_attachments_e2e(self): + """URL-based attachments should NOT be stripped during import.""" + user = factories.UserFactory(email="import-keep-url-attach@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + result = import_service.import_events(user, calendar, ICS_WITH_URL_ATTACHMENT) + + assert result.total_events == 1 + assert result.imported_count == 1 + assert not result.errors + + # Verify URL attachment is preserved in raw ICS + raw = self._get_raw_event(user, calendar, "attach-url-1") + assert "https://example.com/doc.pdf" in raw + assert "ATTACH" in raw + + def test_import_truncates_large_description_e2e(self): + """Descriptions exceeding IMPORT_MAX_DESCRIPTION_BYTES should be truncated.""" + user = factories.UserFactory(email="import-trunc-desc@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + result = import_service.import_events( + user, calendar, ICS_WITH_LARGE_DESCRIPTION + ) + + assert result.total_events == 1 + assert result.imported_count == 1 + assert not result.errors + + # Verify description was truncated (default 100KB limit, original 200KB) + raw = self._get_raw_event(user, calendar, "large-desc-1") + assert "Event with huge description" in raw + # Raw ICS should be much smaller than the 200KB original + assert len(raw) < 150000 + # Should end with truncation marker + assert "..." in raw + + +@pytest.mark.skipif( + not settings.CALDAV_URL, + reason="CalDAV server URL not configured", +) +class TestCalendarSanitizerE2E: + """E2E tests for CalendarSanitizerPlugin on normal CalDAV PUT operations.""" + + def _create_calendar(self, user): + """Create a real calendar in both Django and SabreDAV.""" + service = CalendarService() + return service.create_calendar(user, name="Sanitizer Test", color="#3174ad") + + def _get_raw_event(self, user, calendar, uid): + """Fetch the raw ICS data of a single event from SabreDAV by UID.""" + caldav_client = CalDAVClient() + client = caldav_client._get_client(user) # pylint: disable=protected-access + cal_url = f"{caldav_client.base_url}{calendar.caldav_path}" + cal = client.calendar(url=cal_url) + event = cal.event_by_uid(uid) + return event.data + + def test_caldav_put_strips_binary_attachment_e2e(self): + """A normal CalDAV PUT with binary attachment should be sanitized.""" + user = factories.UserFactory(email="sanitizer-put-attach@example.com") + calendar = self._create_calendar(user) + + caldav = CalDAVClient() + caldav.create_event_raw( + user, calendar.caldav_path, ICS_WITH_BINARY_ATTACHMENT.decode() + ) + + raw = self._get_raw_event(user, calendar, "attach-binary-1") + assert "Event with inline attachment" in raw + assert "iVBORw0KGgo" not in raw + assert "ATTACH" not in raw + + def test_caldav_put_keeps_url_attachment_e2e(self): + """A normal CalDAV PUT with URL attachment should preserve it.""" + user = factories.UserFactory(email="sanitizer-put-url@example.com") + calendar = self._create_calendar(user) + + caldav = CalDAVClient() + caldav.create_event_raw( + user, calendar.caldav_path, ICS_WITH_URL_ATTACHMENT.decode() + ) + + raw = self._get_raw_event(user, calendar, "attach-url-1") + assert "https://example.com/doc.pdf" in raw + assert "ATTACH" in raw + + def test_caldav_put_truncates_large_description_e2e(self): + """A normal CalDAV PUT with oversized description should be truncated.""" + user = factories.UserFactory(email="sanitizer-put-desc@example.com") + calendar = self._create_calendar(user) + + caldav = CalDAVClient() + caldav.create_event_raw( + user, calendar.caldav_path, ICS_WITH_LARGE_DESCRIPTION.decode() + ) + + raw = self._get_raw_event(user, calendar, "large-desc-1") + assert "Event with huge description" in raw + assert len(raw) < 150000 + assert "..." in raw + + def test_caldav_put_rejects_oversized_event_e2e(self): + """A CalDAV PUT exceeding max-resource-size should be rejected (HTTP 507).""" + user = factories.UserFactory(email="sanitizer-put-oversize@example.com") + calendar = self._create_calendar(user) + + caldav = CalDAVClient() + with pytest.raises(Exception) as exc_info: + caldav.create_event_raw( + user, calendar.caldav_path, ICS_OVERSIZED_EVENT.decode() + ) + # SabreDAV returns 507 Insufficient Storage + assert "507" in str(exc_info.value) or "Insufficient" in str(exc_info.value) + + def test_import_rejects_oversized_event_e2e(self): + """Import of an event exceeding max-resource-size should skip it.""" + user = factories.UserFactory(email="sanitizer-import-oversize@example.com") + calendar = self._create_calendar(user) + + import_service = ICSImportService() + result = import_service.import_events(user, calendar, ICS_OVERSIZED_EVENT) + + assert result.total_events == 1 + assert result.imported_count == 0 + assert result.skipped_count == 1 diff --git a/src/frontend/apps/calendars/public/assets/favicon.png b/src/frontend/apps/calendars/public/assets/favicon.png index adf6ce0ac1657838bb656d8c4338466bd96aa13c..746c711f225d348bc902e8b17c028fd2cc34baf8 100644 GIT binary patch delta 596 zcmV-a0;~O=1?>coB!32COGiWi{{a60|De66lK=n$B1uF+RA_ zGkz$EoD6<<^_;-f!Fi z^1#C`~+sQ^Z(#D8+R_rMnAWNiD$L({CJV} z%mXZpJCkc3Vdh#c2|`HUgMNjisN>8gq#%oANjlp)k!ElW@zUn*jZo+#5w7EHyXxes z9Hj7YwB7w+iGR%i%e_}u3n@MU=Nqx-=XWM74WDD*Xx}`*g&gi%A~S%9 zoNLBUc_gU0ab^HjecOzk@&w_Xr7;5(S<(lz2}32(xsdN#A~V3@(>q6?qh>snK}W-# z-VaOVdP;!wYr3g`ucj-2qAzQCserepE5Iqtfvf+)8GoStIq>yAxMu(q*{g1-9A#>` z1Bf!?XLPk%?f{6cRB}@R6>fU9uGi@D=HOxxrbmdN&0d>{%US#XF+*Cnv)wF+T+VB4pTytQWI)(l}!8HeV0Hn3!^KTUU z=(hjJ&`r>co$?6wg=Pjw6Ws!)jW{aDqzK8DB{Bo-KifDIp$80c>_`I?z{e!f-skPL iFD=xIL?V&M8Ridi4YD)qeA1`@0000Px#1ZP1_ zK>z@;j|==^1poj7Gf6~2RCodHnNMyLF%-tX=Y+C_B|)^g0cAl1s}4(c=?&5g069U$ z2_m>aCpVxwb{$rTvLM{RoLqkJD zosg^YV`p=p8GQz}S9~~u;#a-zUsKdbMZj@q^S};%fHfgP+WYZk4>eH|@c!<-fiiS} z|3p@!V72@Br`EV;N&=4WY#lK;!37~u24am%LW~?Ob@=d`&d%+-T=>x0`UU1&2n4a` zjSV$?pmpx+qkl@JfCewfUY!0m`>XmW1dfe_FFtj<-iZxffH0jFeP3G}>q3;<>BxvU z$2G&;^8V%;0!S0n%igI@5g_LXpf?*gX>U~&s2i^UiRr!5ligWqWH(X!xm`>-T7(d( z#=x^wG9Ce>3#R$;VAQpH{Rl8!ghK+nhKO)RA-hqt#ecD&C2_cm@Ys>WDWEWFJj;o7 zcz__`6G>eHay$FkEZg2ju$WBJiU7NLCrh3B2$o?cX+?kmA1+FCA3lu8nALm* z&itPsNci2~Ttz@k{ufFDXlZF;@V`(Jz_eJJ82m4k1Q?^{jdA#2C<%zo|AOa26Nmo= zkAOJ*FMc=##N&U#As` }, +) => { + const apiUrl = new URL(`${baseApiUrl("1.0")}${input}`); + if (init?.params) { + Object.entries(init.params).forEach(([key, value]) => { + apiUrl.searchParams.set(key, String(value)); + }); + } + const csrfToken = getCSRFToken(); + + const response = await fetch(apiUrl, { + ...init, + credentials: "include", + headers: { + ...init?.headers, + ...(csrfToken && { "X-CSRFToken": csrfToken }), + }, + }); + + if (response.ok) { + return response; + } + + const data = await response.text(); + + if (isJson(data)) { + throw new APIError(response.status, JSON.parse(data)); + } + + throw new APIError(response.status); +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/api.ts b/src/frontend/apps/calendars/src/features/calendar/api.ts index 959e271..07826cb 100644 --- a/src/frontend/apps/calendars/src/features/calendar/api.ts +++ b/src/frontend/apps/calendars/src/features/calendar/api.ts @@ -2,7 +2,7 @@ * API functions for calendar operations. */ -import { fetchAPI } from "@/features/api/fetchApi"; +import { fetchAPI, fetchAPIFormData } from "@/features/api/fetchApi"; export interface Calendar { id: string; @@ -11,6 +11,7 @@ export interface Calendar { description: string; is_default: boolean; is_visible: boolean; + caldav_path: string; owner: string; } @@ -204,3 +205,34 @@ export const deleteSubscriptionToken = async ( ); }; +/** + * Result of an ICS import operation. + */ +export interface ImportEventsResult { + total_events: number; + imported_count: number; + duplicate_count: number; + skipped_count: number; + errors?: string[]; +} + +/** + * Import events from an ICS file into a calendar. + */ +export const importEventsApi = async ( + calendarId: string, + file: File, +): Promise => { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetchAPIFormData( + `calendars/${calendarId}/import_events/`, + { + method: "POST", + body: formData, + }, + ); + return response.json(); +}; + diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarItemMenu.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarItemMenu.tsx index 110550d..726954f 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarItemMenu.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarItemMenu.tsx @@ -15,6 +15,7 @@ export const CalendarItemMenu = ({ onOpenChange, onEdit, onDelete, + onImport, onSubscription, }: CalendarItemMenuProps) => { const { t } = useTranslation(); @@ -28,6 +29,14 @@ export const CalendarItemMenu = ({ }, ]; + if (onImport) { + items.push({ + label: t("calendar.list.import"), + icon: upload_file, + callback: onImport, + }); + } + if (onSubscription) { items.push({ label: t("calendar.list.subscription"), @@ -43,7 +52,7 @@ export const CalendarItemMenu = ({ }); return items; - }, [t, onEdit, onDelete, onSubscription]); + }, [t, onEdit, onDelete, onImport, onSubscription]); return ( diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.scss b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.scss index 16c6e32..6bdc081 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.scss +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.scss @@ -389,3 +389,110 @@ padding-top: 0.5rem; } } + +// ============================================================================ +// Import Events Modal Styles +// ============================================================================ + +.import-events-modal { + display: flex; + flex-direction: column; + gap: 1rem; + + &__description { + font-size: 0.875rem; + color: var(--c--theme--colors--greyscale-600); + margin: 0; + line-height: 1.5; + } + + &__file-section { + display: flex; + align-items: center; + gap: 0.75rem; + } + + &__filename { + font-size: 0.875rem; + color: var(--c--theme--colors--greyscale-700); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__error { + padding: 12px 16px; + background-color: #fef2f2; + color: #dc2626; + border-radius: 8px; + font-size: 0.875rem; + border: 1px solid #fecaca; + } + + &__result { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + &__result-header { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--c--theme--colors--greyscale-800); + } + + &__stats { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + &__stat { + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.5rem; + + .material-icons { + font-size: 1.125rem; + } + + &--success { + color: #16a34a; + } + + &--neutral { + color: var(--c--theme--colors--greyscale-500); + } + + &--warning { + color: #d97706; + } + } + + &__errors { + font-size: 0.875rem; + color: var(--c--theme--colors--greyscale-600); + + summary { + cursor: pointer; + user-select: none; + padding: 0.25rem 0; + } + + ul { + margin: 0.5rem 0 0; + padding-left: 1.25rem; + + li { + margin-bottom: 0.25rem; + font-size: 0.8rem; + color: #d97706; + } + } + } +} diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx index 84d01b5..851afaa 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx @@ -2,22 +2,26 @@ * CalendarList component - List of calendars with visibility toggles. */ -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; import type { Calendar } from "../../types"; import { useCalendarContext } from "../../contexts"; import { CalendarModal } from "./CalendarModal"; import { DeleteConfirmModal } from "./DeleteConfirmModal"; +import { ImportEventsModal } from "./ImportEventsModal"; import { SubscriptionUrlModal } from "./SubscriptionUrlModal"; import { CalendarListItem, SharedCalendarListItem } from "./CalendarListItem"; import { useCalendarListState } from "./hooks/useCalendarListState"; import type { CalendarListProps } from "./types"; import type { CalDavCalendar } from "../../services/dav/types/caldav-service"; +import { Calendar as DjangoCalendar, getCalendars } from "../../api"; export const CalendarList = ({ calendars }: CalendarListProps) => { const { t } = useTranslation(); + const queryClient = useQueryClient(); const { davCalendars, visibleCalendarUrls, @@ -26,6 +30,7 @@ export const CalendarList = ({ calendars }: CalendarListProps) => { updateCalendar, deleteCalendar, shareCalendar, + calendarRef, } = useCalendarContext(); const { @@ -108,6 +113,68 @@ export const CalendarList = ({ calendars }: CalendarListProps) => { // Ensure calendars is an array const calendarsArray = Array.isArray(calendars) ? calendars : []; + // Import modal state + const [importModal, setImportModal] = useState<{ + isOpen: boolean; + calendarId: string | null; + calendarName: string; + }>({ isOpen: false, calendarId: null, calendarName: "" }); + + const handleOpenImportModal = async (davCalendar: CalDavCalendar) => { + try { + // Extract the CalDAV path from the calendar URL + const url = new URL(davCalendar.url); + const pathParts = url.pathname.split("/").filter(Boolean); + const calendarsIndex = pathParts.findIndex((part) => part === "calendars"); + + if (calendarsIndex === -1 || pathParts.slice(calendarsIndex).length < 3) { + console.error("Invalid calendar URL format:", davCalendar.url); + return; + } + + const caldavPath = "/" + pathParts.slice(calendarsIndex).join("/") + "/"; + + // Find the matching Django Calendar by caldav_path + const caldavApiRoot = "/api/v1.0/caldav"; + const normalize = (p: string) => + decodeURIComponent(p).replace(caldavApiRoot, "").replace(/\/+$/, ""); + + const findCalendar = (cals: DjangoCalendar[]) => + cals.find((cal) => normalize(cal.caldav_path) === normalize(caldavPath)); + + // Fetch fresh Django calendars to ensure newly created calendars are included. + // Uses React Query cache, forcing a refetch if stale. + const freshCalendars = await queryClient.fetchQuery({ + queryKey: ["calendars"], + queryFn: getCalendars, + }); + const djangoCalendar = findCalendar(freshCalendars); + + if (!djangoCalendar) { + console.error("No matching Django calendar found for path:", caldavPath); + return; + } + + setImportModal({ + isOpen: true, + calendarId: djangoCalendar.id, + calendarName: davCalendar.displayName || "", + }); + } catch (error) { + console.error("Failed to parse calendar URL:", error); + } + }; + + const handleCloseImportModal = () => { + setImportModal({ isOpen: false, calendarId: null, calendarName: "" }); + }; + + const handleImportSuccess = useCallback(() => { + if (calendarRef.current) { + calendarRef.current.refetchEvents(); + } + }, [calendarRef]); + // Use translation key for shared marker const sharedMarker = t('calendar.list.shared'); @@ -160,6 +227,7 @@ export const CalendarList = ({ calendars }: CalendarListProps) => { onMenuToggle={handleMenuToggle} onEdit={handleOpenEditModal} onDelete={handleOpenDeleteModal} + onImport={handleOpenImportModal} onSubscription={handleOpenSubscriptionModal} onCloseMenu={handleCloseMenu} /> @@ -231,6 +299,16 @@ export const CalendarList = ({ calendars }: CalendarListProps) => { onClose={handleCloseSubscriptionModal} /> )} + + {importModal.isOpen && importModal.calendarId && ( + + )} ); }; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarListItem.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarListItem.tsx index 00951db..cbeeaed 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarListItem.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarListItem.tsx @@ -23,6 +23,7 @@ export const CalendarListItem = ({ onMenuToggle, onEdit, onDelete, + onImport, onSubscription, onCloseMenu, }: CalendarListItemProps) => { @@ -55,6 +56,9 @@ export const CalendarListItem = ({ } onEdit={() => onEdit(calendar)} onDelete={() => onDelete(calendar)} + onImport={ + onImport ? () => onImport(calendar) : undefined + } onSubscription={ onSubscription ? () => onSubscription(calendar) : undefined } diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/ImportEventsModal.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/ImportEventsModal.tsx new file mode 100644 index 0000000..a713d0f --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/ImportEventsModal.tsx @@ -0,0 +1,173 @@ +/** + * ImportEventsModal component. + * Allows users to import events from an ICS file into a calendar. + */ + +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react"; +import { Spinner } from "@gouvfr-lasuite/ui-kit"; + +import { useImportEvents } from "../../hooks/useCalendars"; +import type { ImportEventsResult } from "../../api"; + +interface ImportEventsModalProps { + isOpen: boolean; + calendarId: string; + calendarName: string; + onClose: () => void; + onImportSuccess?: () => void; +} + +export const ImportEventsModal = ({ + isOpen, + calendarId, + calendarName, + onClose, + onImportSuccess, +}: ImportEventsModalProps) => { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [result, setResult] = useState(null); + const importMutation = useImportEvents(); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + setSelectedFile(file); + setResult(null); + }; + + const handleImport = async () => { + if (!selectedFile) return; + + const importResult = await importMutation.mutateAsync({ + calendarId, + file: selectedFile, + }); + setResult(importResult); + if (importResult.imported_count > 0) { + onImportSuccess?.(); + } + }; + + const handleClose = () => { + setSelectedFile(null); + setResult(null); + importMutation.reset(); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + onClose(); + }; + + const hasResult = result !== null; + const hasErrors = result && result.errors && result.errors.length > 0; + + return ( + + {t("calendar.subscription.close")} + + ) : ( + <> + + + + ) + } + > +
+

+ {t("calendar.importEvents.description", { name: calendarName })} +

+ + {!hasResult && ( +
+ + + {selectedFile && ( + + {selectedFile.name} + + )} +
+ )} + + {importMutation.isError && !hasResult && ( +
+ {t("calendar.importEvents.error")} +
+ )} + + {hasResult && ( +
+

+ {t("calendar.importEvents.resultHeader")} +

+
    + {result.imported_count > 0 && ( +
  • + check_circle + {result.imported_count} {t("calendar.importEvents.imported")} +
  • + )} + {result.duplicate_count > 0 && ( +
  • + content_copy + {result.duplicate_count} {t("calendar.importEvents.duplicates")} +
  • + )} + {result.skipped_count > 0 && ( +
  • + warning_amber + {result.skipped_count} {t("calendar.importEvents.skipped")} +
  • + )} +
+ {hasErrors && ( +
+ {t("calendar.importEvents.errorDetails")} +
    + {result.errors!.map((error, index) => ( +
  • {error}
  • + ))} +
+
+ )} +
+ )} +
+
+ ); +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts index 7c71d37..374fa95 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts @@ -25,6 +25,7 @@ export interface CalendarItemMenuProps { onOpenChange: (isOpen: boolean) => void; onEdit: () => void; onDelete: () => void; + onImport?: () => void; onSubscription?: () => void; } @@ -50,6 +51,7 @@ export interface CalendarListItemProps { onMenuToggle: (url: string) => void; onEdit: (calendar: CalDavCalendar) => void; onDelete: (calendar: CalDavCalendar) => void; + onImport?: (calendar: CalDavCalendar) => void; onSubscription?: (calendar: CalDavCalendar) => void; onCloseMenu: () => void; } diff --git a/src/frontend/apps/calendars/src/features/calendar/hooks/useCalendars.ts b/src/frontend/apps/calendars/src/features/calendar/hooks/useCalendars.ts index 4544bd1..1161e8d 100644 --- a/src/frontend/apps/calendars/src/features/calendar/hooks/useCalendars.ts +++ b/src/frontend/apps/calendars/src/features/calendar/hooks/useCalendars.ts @@ -12,6 +12,8 @@ import { getCalendars, getSubscriptionToken, GetSubscriptionTokenResult, + importEventsApi, + ImportEventsResult, SubscriptionToken, SubscriptionTokenError, SubscriptionTokenParams, @@ -132,3 +134,21 @@ export const useDeleteSubscriptionToken = () => { }, }); }; + +/** + * Hook to import events from an ICS file. + */ +export const useImportEvents = () => { + const queryClient = useQueryClient(); + + return useMutation< + ImportEventsResult, + Error, + { calendarId: string; file: File } + >({ + mutationFn: ({ calendarId, file }) => importEventsApi(calendarId, file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: CALENDARS_KEY }); + }, + }); +}; diff --git a/src/frontend/apps/calendars/src/features/i18n/translations.json b/src/frontend/apps/calendars/src/features/i18n/translations.json index 80c7557..54fb7a3 100644 --- a/src/frontend/apps/calendars/src/features/i18n/translations.json +++ b/src/frontend/apps/calendars/src/features/i18n/translations.json @@ -182,7 +182,21 @@ "showCalendar": "Show calendar", "edit": "Edit", "delete": "Delete", - "subscription": "Subscription URL" + "import": "Import events", + "subscription": "Subscription URL", + "options": "Options" + }, + "importEvents": { + "title": "Import events", + "description": "Import events from an ICS file into \"{{name}}\".", + "selectFile": "Select file", + "import": "Import", + "resultHeader": "Import results", + "imported": "events imported", + "duplicates": "events already existed", + "skipped": "events skipped (unsupported format)", + "error": "An error occurred during import. Please try again.", + "errorDetails": "Error details" }, "subscription": { "title": "Calendar Subscription URL", @@ -793,7 +807,21 @@ "showCalendar": "Afficher le calendrier", "edit": "Modifier", "delete": "Supprimer", - "subscription": "URL d'abonnement" + "import": "Importer des événements", + "subscription": "URL d'abonnement", + "options": "Options" + }, + "importEvents": { + "title": "Importer des événements", + "description": "Importer des événements depuis un fichier ICS dans \"{{name}}\".", + "selectFile": "Choisir un fichier", + "import": "Importer", + "resultHeader": "Résultats de l'import", + "imported": "événements importés", + "duplicates": "événements existaient déjà", + "skipped": "événements ignorés (format non supporté)", + "error": "Une erreur est survenue lors de l'importation. Veuillez réessayer.", + "errorDetails": "Détails des erreurs" }, "subscription": { "title": "URL d'abonnement au calendrier", @@ -1151,7 +1179,21 @@ "showCalendar": "Agenda tonen", "edit": "Bewerken", "delete": "Verwijderen", - "subscription": "Abonnements-URL" + "import": "Evenementen importeren", + "subscription": "Abonnements-URL", + "options": "Opties" + }, + "importEvents": { + "title": "Evenementen importeren", + "description": "Importeer evenementen vanuit een ICS-bestand in \"{{name}}\".", + "selectFile": "Bestand kiezen", + "import": "Importeren", + "resultHeader": "Importresultaten", + "imported": "evenementen geïmporteerd", + "duplicates": "evenementen bestonden al", + "skipped": "evenementen overgeslagen (niet-ondersteund formaat)", + "error": "Er is een fout opgetreden tijdens het importeren. Probeer het opnieuw.", + "errorDetails": "Foutdetails" }, "subscription": { "title": "Agenda-abonnements-URL",