✨(import) add import modal
Synchronous for now, can be offloaded to worker later. Also lint the codebase
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
115
src/backend/core/services/import_service.py
Normal file
115
src/backend/core/services/import_service.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
1088
src/backend/core/tests/test_import_events.py
Normal file
1088
src/backend/core/tests/test_import_events.py
Normal file
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 |
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user