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