(import) add import modal

Synchronous for now, can be offloaded to worker later.

Also lint the codebase
This commit is contained in:
Sylvain Zimmer
2026-02-09 18:43:49 +01:00
parent 23a66f21e6
commit 3a0f64e791
30 changed files with 2476 additions and 121 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 B

After

Width:  |  Height:  |  Size: 621 B

View File

@@ -53,3 +53,37 @@ export const fetchAPI = async (
throw new APIError(response.status);
};
export const fetchAPIFormData = async (
input: string,
init?: RequestInit & { params?: Record<string, string | number> },
) => {
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);
};

View File

@@ -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<ImportEventsResult> => {
const formData = new FormData();
formData.append("file", file);
const response = await fetchAPIFormData(
`calendars/${calendarId}/import_events/`,
{
method: "POST",
body: formData,
},
);
return response.json();
};

View File

@@ -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: <span className="material-icons">upload_file</span>,
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 (
<DropdownMenu options={options} isOpen={isOpen} onOpenChange={onOpenChange}>

View File

@@ -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;
}
}
}
}

View File

@@ -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 && (
<ImportEventsModal
isOpen={importModal.isOpen}
calendarId={importModal.calendarId}
calendarName={importModal.calendarName}
onClose={handleCloseImportModal}
onImportSuccess={handleImportSuccess}
/>
)}
</>
);
};

View File

@@ -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
}

View File

@@ -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<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [result, setResult] = useState<ImportEventsResult | null>(null);
const importMutation = useImportEvents();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Modal
isOpen={isOpen}
onClose={handleClose}
size={ModalSize.MEDIUM}
title={t("calendar.importEvents.title")}
rightActions={
hasResult ? (
<Button color="brand" onClick={handleClose}>
{t("calendar.subscription.close")}
</Button>
) : (
<>
<Button color="neutral" onClick={handleClose}>
{t("calendar.event.cancel")}
</Button>
<Button
color="brand"
onClick={handleImport}
disabled={!selectedFile || importMutation.isPending}
>
{importMutation.isPending
? <Spinner size="sm" />
: t("calendar.importEvents.import")}
</Button>
</>
)
}
>
<div className="import-events-modal">
<p className="import-events-modal__description">
{t("calendar.importEvents.description", { name: calendarName })}
</p>
{!hasResult && (
<div className="import-events-modal__file-section">
<input
ref={fileInputRef}
type="file"
accept=".ics,.ical,.ifb,.icalendar,text/calendar"
onChange={handleFileChange}
style={{ display: "none" }}
/>
<Button
color="neutral"
onClick={() => fileInputRef.current?.click()}
icon={
<span className="material-icons">upload_file</span>
}
>
{t("calendar.importEvents.selectFile")}
</Button>
{selectedFile && (
<span className="import-events-modal__filename">
{selectedFile.name}
</span>
)}
</div>
)}
{importMutation.isError && !hasResult && (
<div className="import-events-modal__error">
{t("calendar.importEvents.error")}
</div>
)}
{hasResult && (
<div className="import-events-modal__result">
<p className="import-events-modal__result-header">
{t("calendar.importEvents.resultHeader")}
</p>
<ul className="import-events-modal__stats">
{result.imported_count > 0 && (
<li className="import-events-modal__stat import-events-modal__stat--success">
<span className="material-icons">check_circle</span>
<span><strong>{result.imported_count}</strong> {t("calendar.importEvents.imported")}</span>
</li>
)}
{result.duplicate_count > 0 && (
<li className="import-events-modal__stat import-events-modal__stat--neutral">
<span className="material-icons">content_copy</span>
<span><strong>{result.duplicate_count}</strong> {t("calendar.importEvents.duplicates")}</span>
</li>
)}
{result.skipped_count > 0 && (
<li className="import-events-modal__stat import-events-modal__stat--warning">
<span className="material-icons">warning_amber</span>
<span><strong>{result.skipped_count}</strong> {t("calendar.importEvents.skipped")}</span>
</li>
)}
</ul>
{hasErrors && (
<details className="import-events-modal__errors">
<summary>{t("calendar.importEvents.errorDetails")}</summary>
<ul>
{result.errors!.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</details>
)}
</div>
)}
</div>
</Modal>
);
};

View File

@@ -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;
}

View File

@@ -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 });
},
});
};

View File

@@ -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",