✨(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
Reference in New Issue
Block a user