🐛(data) remove Calendar and CalendarShare models

The only source of truth for those is now in the caldav server
This commit is contained in:
Sylvain Zimmer
2026-02-09 20:48:11 +01:00
parent 3a0f64e791
commit 3051100f8a
24 changed files with 408 additions and 883 deletions

View File

@@ -93,16 +93,6 @@ class UserAdmin(auth_admin.UserAdmin):
search_fields = ("id", "sub", "admin_email", "email", "full_name") search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.Calendar)
class CalendarAdmin(admin.ModelAdmin):
"""Admin class for Calendar model."""
list_display = ("name", "owner", "is_default", "is_visible", "created_at")
list_filter = ("is_default", "is_visible")
search_fields = ("name", "owner__email", "caldav_path")
readonly_fields = ("id", "created_at", "updated_at")
@admin.register(models.CalendarSubscriptionToken) @admin.register(models.CalendarSubscriptionToken)
class CalendarSubscriptionTokenAdmin(admin.ModelAdmin): class CalendarSubscriptionTokenAdmin(admin.ModelAdmin):
"""Admin class for CalendarSubscriptionToken model.""" """Admin class for CalendarSubscriptionToken model."""

View File

@@ -114,49 +114,12 @@ class UserMeSerializer(UserSerializer):
read_only_fields = UserSerializer.Meta.read_only_fields read_only_fields = UserSerializer.Meta.read_only_fields
# CalDAV serializers class CalendarCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method
class CalendarSerializer(serializers.ModelSerializer): """Serializer for creating a Calendar (CalDAV only, no Django model)."""
"""Serializer for Calendar model."""
class Meta: name = serializers.CharField(max_length=255)
model = models.Calendar color = serializers.CharField(max_length=7, required=False, default="#3174ad")
fields = [ description = serializers.CharField(required=False, default="")
"id",
"name",
"color",
"description",
"is_default",
"is_visible",
"caldav_path",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"is_default",
"caldav_path",
"created_at",
"updated_at",
]
class CalendarCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating a Calendar."""
class Meta:
model = models.Calendar
fields = ["name", "color", "description"]
class CalendarShareSerializer(serializers.ModelSerializer):
"""Serializer for CalendarShare model."""
shared_with_email = serializers.EmailField(write_only=True)
class Meta:
model = models.CalendarShare
fields = ["id", "shared_with_email", "permission", "is_visible", "created_at"]
read_only_fields = ["id", "created_at"]
class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer): class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer):

View File

@@ -8,12 +8,11 @@ from urllib.parse import unquote
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.db import models as db
from django.utils.text import slugify from django.utils.text import slugify
import rest_framework as drf import rest_framework as drf
from rest_framework import mixins, status, viewsets
from rest_framework import response as drf_response from rest_framework import response as drf_response
from rest_framework import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
@@ -262,149 +261,105 @@ class ConfigView(drf.views.APIView):
return theme_customization return theme_customization
# CalDAV ViewSets # Regex for CalDAV path validation (shared with SubscriptionTokenViewSet)
class CalendarViewSet( # Pattern: /calendars/<email-or-encoded>/<calendar-id>/
mixins.ListModelMixin, CALDAV_PATH_PATTERN = re.compile(
mixins.RetrieveModelMixin, r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$",
mixins.CreateModelMixin, )
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""
ViewSet for managing user calendars.
list: Get all calendars accessible by the user (owned + shared)
retrieve: Get a specific calendar def _verify_caldav_access(user, caldav_path):
create: Create a new calendar """Verify that the user has access to the CalDAV calendar.
update: Update calendar properties
destroy: Delete a calendar Checks that:
1. The path matches the expected pattern (prevents path injection)
2. The user's email matches the email in the path
"""
if not CALDAV_PATH_PATTERN.match(caldav_path):
return False
parts = caldav_path.strip("/").split("/")
if len(parts) >= 2 and parts[0] == "calendars":
path_email = unquote(parts[1])
return path_email.lower() == user.email.lower()
return False
def _normalize_caldav_path(caldav_path):
"""Normalize CalDAV path to consistent format.
Strips the CalDAV API prefix (e.g. /api/v1.0/caldav/) if present,
so that paths like /api/v1.0/caldav/calendars/user@ex.com/uuid/
become /calendars/user@ex.com/uuid/.
"""
if not caldav_path.startswith("/"):
caldav_path = "/" + caldav_path
# Strip CalDAV API prefix — keep from /calendars/ onwards
calendars_idx = caldav_path.find("/calendars/")
if calendars_idx > 0:
caldav_path = caldav_path[calendars_idx:]
if not caldav_path.endswith("/"):
caldav_path = caldav_path + "/"
return caldav_path
class CalendarViewSet(viewsets.GenericViewSet):
"""ViewSet for calendar operations.
create: Create a new calendar (CalDAV only, no Django record).
import_events: Import events from an ICS file.
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
serializer_class = serializers.CalendarSerializer serializer_class = serializers.CalendarCreateSerializer
def get_queryset(self): def create(self, request):
"""Return calendars owned by or shared with the current user.""" """Create a new calendar via CalDAV.
user = self.request.user
shared_ids = models.CalendarShare.objects.filter(shared_with=user).values_list(
"calendar_id", flat=True
)
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): POST /api/v1.0/calendars/
if self.action == "create": Body: { name, color?, description? }
return serializers.CalendarCreateSerializer Returns: { caldav_path }
return serializers.CalendarSerializer """
serializer = serializers.CalendarCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
def perform_create(self, serializer):
"""Create a new calendar via CalendarService."""
service = CalendarService() service = CalendarService()
calendar = service.create_calendar( caldav_path = service.create_calendar(
user=self.request.user, user=request.user,
name=serializer.validated_data["name"], name=serializer.validated_data["name"],
color=serializer.validated_data.get("color", "#3174ad"), color=serializer.validated_data.get("color", "#3174ad"),
) )
# Update the serializer instance with the created calendar
serializer.instance = calendar
def perform_destroy(self, instance):
"""Delete calendar. Prevent deletion of default calendar."""
if instance.is_default:
raise ValueError("Cannot delete the default calendar.")
if instance.owner != self.request.user:
raise PermissionError("You can only delete your own calendars.")
instance.delete()
@action(detail=True, methods=["patch"])
def toggle_visibility(self, request, **kwargs):
"""Toggle calendar visibility."""
calendar = self.get_object()
# Check if it's a shared calendar
share = models.CalendarShare.objects.filter(
calendar=calendar, shared_with=request.user
).first()
if share:
share.is_visible = not share.is_visible
share.save()
is_visible = share.is_visible
elif calendar.owner == request.user:
calendar.is_visible = not calendar.is_visible
calendar.save()
is_visible = calendar.is_visible
else:
return drf_response.Response(
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
)
return drf_response.Response({"is_visible": is_visible})
@action(
detail=True,
methods=["post"],
serializer_class=serializers.CalendarShareSerializer,
)
def share(self, request, **kwargs):
"""Share calendar with another user."""
calendar = self.get_object()
if calendar.owner != request.user:
return drf_response.Response(
{"error": "Only the owner can share this calendar"},
status=status.HTTP_403_FORBIDDEN,
)
serializer = serializers.CalendarShareSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data["shared_with_email"]
try:
user_to_share = models.User.objects.get(email=email)
except models.User.DoesNotExist:
return drf_response.Response(
{"error": "User not found"}, status=status.HTTP_404_NOT_FOUND
)
share, created = models.CalendarShare.objects.get_or_create(
calendar=calendar,
shared_with=user_to_share,
defaults={
"permission": serializer.validated_data.get("permission", "read")
},
)
if not created:
share.permission = serializer.validated_data.get(
"permission", share.permission
)
share.save()
return drf_response.Response( return drf_response.Response(
serializers.CalendarShareSerializer(share).data, {"caldav_path": caldav_path},
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, status=status.HTTP_201_CREATED,
) )
@action( @action(
detail=True, detail=False,
methods=["post"], methods=["post"],
parser_classes=[MultiPartParser], parser_classes=[MultiPartParser],
url_path="import_events", url_path="import-events",
url_name="import-events", url_name="import-events",
) )
def import_events(self, request, **kwargs): def import_events(self, request, **kwargs):
"""Import events from an ICS file into this calendar.""" """Import events from an ICS file into a calendar.
calendar = self.get_object()
# Only the owner can import events POST /api/v1.0/calendars/import-events/
if calendar.owner != request.user: Body (multipart): file=<ics>, caldav_path=/calendars/user@.../uuid/
"""
caldav_path = request.data.get("caldav_path", "")
if not caldav_path:
return drf_response.Response( return drf_response.Response(
{"error": "Only the owner can import events"}, {"error": "caldav_path is required"},
status=status.HTTP_400_BAD_REQUEST,
)
caldav_path = _normalize_caldav_path(caldav_path)
# Verify user access
if not _verify_caldav_access(request.user, caldav_path):
return drf_response.Response(
{"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
@@ -426,7 +381,7 @@ class CalendarViewSet(
ics_data = uploaded_file.read() ics_data = uploaded_file.read()
service = ICSImportService() service = ICSImportService()
result = service.import_events(request.user, calendar, ics_data) result = service.import_events(request.user, caldav_path, ics_data)
response_data = { response_data = {
"total_events": result.total_events, "total_events": result.total_events,
@@ -457,48 +412,6 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
serializer_class = serializers.CalendarSubscriptionTokenSerializer serializer_class = serializers.CalendarSubscriptionTokenSerializer
# Regex for CalDAV path validation
# Pattern: /calendars/<email>/<calendar-id>/
# Calendar ID: alphanumeric with hyphens only (prevents path traversal like ../)
# This blocks injection attacks while allowing UUIDs and test identifiers
CALDAV_PATH_PATTERN = re.compile(
r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$",
)
def _verify_caldav_access(self, user, caldav_path):
"""
Verify that the user has access to the CalDAV calendar.
We verify by checking:
1. The path matches the expected pattern (prevents path injection)
2. The user's email matches the email in the path
CalDAV paths follow the pattern: /calendars/<user_email>/<calendar_id>/
"""
# Format validation to prevent path injection attacks (e.g., ../, query params)
if not self.CALDAV_PATH_PATTERN.match(caldav_path):
logger.warning(
"Invalid CalDAV path format rejected: %s",
caldav_path[:100], # Truncate for logging
)
return False
# Extract and verify email from path
# Path format: /calendars/user@example.com/calendar-id/
parts = caldav_path.strip("/").split("/")
if len(parts) >= 2 and parts[0] == "calendars":
path_email = unquote(parts[1])
return path_email.lower() == user.email.lower()
return False
def _normalize_caldav_path(self, caldav_path):
"""Normalize CalDAV path to consistent format."""
if not caldav_path.startswith("/"):
caldav_path = "/" + caldav_path
if not caldav_path.endswith("/"):
caldav_path = caldav_path + "/"
return caldav_path
def create(self, request): def create(self, request):
""" """
Create or get existing subscription token. Create or get existing subscription token.
@@ -516,7 +429,7 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet):
calendar_name = create_serializer.validated_data.get("calendar_name", "") calendar_name = create_serializer.validated_data.get("calendar_name", "")
# Verify user has access to this calendar # Verify user has access to this calendar
if not self._verify_caldav_access(request.user, caldav_path): if not _verify_caldav_access(request.user, caldav_path):
return drf_response.Response( return drf_response.Response(
{"error": "You don't have access to this calendar"}, {"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
@@ -555,10 +468,10 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
caldav_path = self._normalize_caldav_path(caldav_path) caldav_path = _normalize_caldav_path(caldav_path)
# Verify user has access to this calendar # Verify user has access to this calendar
if not self._verify_caldav_access(request.user, caldav_path): if not _verify_caldav_access(request.user, caldav_path):
return drf_response.Response( return drf_response.Response(
{"error": "You don't have access to this calendar"}, {"error": "You don't have access to this calendar"},
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,

View File

@@ -28,23 +28,6 @@ class UserFactory(factory.django.DjangoModelFactory):
password = make_password("password") password = make_password("password")
class CalendarFactory(factory.django.DjangoModelFactory):
"""A factory to create calendars for testing purposes."""
class Meta:
model = models.Calendar
owner = factory.SubFactory(UserFactory)
name = factory.Faker("sentence", nb_words=3)
color = factory.Faker("hex_color")
description = factory.Faker("paragraph")
is_default = False
is_visible = True
caldav_path = factory.LazyAttribute(
lambda obj: f"/calendars/{obj.owner.email}/{fake.uuid4()}"
)
class CalendarSubscriptionTokenFactory(factory.django.DjangoModelFactory): class CalendarSubscriptionTokenFactory(factory.django.DjangoModelFactory):
"""A factory to create calendar subscription tokens for testing purposes.""" """A factory to create calendar subscription tokens for testing purposes."""

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.9 on 2026-02-09 18:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0003_calendarsubscriptiontoken_token_active_idx'),
]
operations = [
migrations.DeleteModel(
name='CalendarShare',
),
migrations.DeleteModel(
name='Calendar',
),
]

View File

@@ -1,27 +1,16 @@
""" """
Declare and configure the models for the calendars core application Declare and configure the models for the calendars core application
""" """
# pylint: disable=too-many-lines
import uuid import uuid
from datetime import timedelta
from logging import getLogger from logging import getLogger
from django.conf import settings from django.conf import settings
from django.contrib.auth import models as auth_models from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.indexes import GistIndex
from django.contrib.sites.models import Site
from django.core import mail, validators from django.core import mail, validators
from django.core.cache import cache from django.db import models
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models.expressions import RawSQL
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
@@ -225,14 +214,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
def __str__(self): def __str__(self):
return self.email or self.admin_email or str(self.id) return self.email or self.admin_email or str(self.id)
def save(self, *args, **kwargs):
"""
If it's a new user, give its user access to the items to which s.he was invited.
"""
is_adding = self._state.adding
super().save(*args, **kwargs)
def email_user(self, subject, message, from_email=None, **kwargs): def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user.""" """Email this user."""
if not self.email: if not self.email:
@@ -321,87 +302,6 @@ class BaseAccess(BaseModel):
} }
class Calendar(models.Model):
"""
Represents a calendar owned by a user.
This model tracks calendars stored in the CalDAV server and links them to Django users.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="calendars",
)
name = models.CharField(max_length=255)
color = models.CharField(max_length=7, default="#3174ad") # Hex color
description = models.TextField(blank=True, default="")
is_default = models.BooleanField(default=False)
is_visible = models.BooleanField(default=True)
# CalDAV server reference - the calendar path in the CalDAV server
caldav_path = models.CharField(max_length=512, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
"""Meta options for Calendar model."""
ordering = ["-is_default", "name"]
constraints = [
models.UniqueConstraint(
fields=["owner"],
condition=models.Q(is_default=True),
name="unique_default_calendar_per_user",
)
]
def __str__(self):
return f"{self.name} ({self.owner.email})"
class CalendarShare(models.Model):
"""
Represents a calendar shared with another user.
"""
PERMISSION_READ = "read"
PERMISSION_WRITE = "write"
PERMISSION_CHOICES = [
(PERMISSION_READ, "Read only"),
(PERMISSION_WRITE, "Read and write"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
calendar = models.ForeignKey(
Calendar,
on_delete=models.CASCADE,
related_name="shares",
)
shared_with = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="shared_calendars",
)
permission = models.CharField(
max_length=10,
choices=PERMISSION_CHOICES,
default=PERMISSION_READ,
)
is_visible = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
"""Meta options for CalendarShare model."""
unique_together = ["calendar", "shared_with"]
def __str__(self):
return f"{self.calendar.name} shared with {self.shared_with.email}"
class CalendarSubscriptionToken(models.Model): class CalendarSubscriptionToken(models.Model):
""" """
Stores subscription tokens for iCal export. Stores subscription tokens for iCal export.

View File

@@ -10,7 +10,6 @@ from django.utils import timezone
from caldav import DAVClient from caldav import DAVClient
from caldav.lib.error import NotFoundError from caldav.lib.error import NotFoundError
from core.models import Calendar
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -385,72 +384,33 @@ class CalendarService:
def __init__(self): def __init__(self):
self.caldav = CalDAVClient() self.caldav = CalDAVClient()
def create_default_calendar(self, user) -> Calendar: def create_default_calendar(self, user) -> str:
""" """Create a default calendar for a user. Returns the caldav_path."""
Create a default calendar for a user.
"""
calendar_id = str(uuid4()) calendar_id = str(uuid4())
calendar_name = "Mon calendrier" calendar_name = "Mon calendrier"
return self.caldav.create_calendar(user, calendar_name, calendar_id)
# Create calendar in CalDAV server def create_calendar( # pylint: disable=unused-argument
caldav_path = self.caldav.create_calendar(user, calendar_name, calendar_id) self, user, name: str, color: str = "#3174ad"
) -> str:
# Create local Calendar record """Create a new calendar for a user. Returns the caldav_path."""
calendar = Calendar.objects.create(
owner=user,
name=calendar_name,
caldav_path=caldav_path,
is_default=True,
color="#3174ad",
)
return calendar
def create_calendar(self, user, name: str, color: str = "#3174ad") -> Calendar:
"""
Create a new calendar for a user.
"""
calendar_id = str(uuid4()) calendar_id = str(uuid4())
return self.caldav.create_calendar(user, name, calendar_id)
# Create calendar in CalDAV server def get_events(self, user, caldav_path: str, start=None, end=None) -> list:
caldav_path = self.caldav.create_calendar(user, name, calendar_id) """Get events from a calendar. Returns parsed event data."""
return self.caldav.get_events(user, caldav_path, start, end)
# Create local Calendar record def create_event(self, user, caldav_path: str, event_data: dict) -> str:
calendar = Calendar.objects.create(
owner=user,
name=name,
caldav_path=caldav_path,
is_default=False,
color=color,
)
return calendar
def get_user_calendars(self, user):
"""
Get all calendars accessible by a user (owned + shared).
"""
owned = Calendar.objects.filter(owner=user)
shared = Calendar.objects.filter(shares__shared_with=user)
return owned.union(shared)
def get_events(self, user, calendar: Calendar, start=None, end=None) -> list:
"""
Get events from a calendar.
Returns parsed event data.
"""
return self.caldav.get_events(user, calendar.caldav_path, start, end)
def create_event(self, user, calendar: Calendar, event_data: dict) -> str:
"""Create a new event.""" """Create a new event."""
return self.caldav.create_event(user, calendar.caldav_path, event_data) return self.caldav.create_event(user, caldav_path, event_data)
def update_event( def update_event(
self, user, calendar: Calendar, event_uid: str, event_data: dict self, user, caldav_path: str, event_uid: str, event_data: dict
) -> None: ) -> None:
"""Update an existing event.""" """Update an existing event."""
self.caldav.update_event(user, calendar.caldav_path, event_uid, event_data) self.caldav.update_event(user, caldav_path, event_uid, event_data)
def delete_event(self, user, calendar: Calendar, event_uid: str) -> None: def delete_event(self, user, caldav_path: str, event_uid: str) -> None:
"""Delete an event.""" """Delete an event."""
self.caldav.delete_event(user, calendar.caldav_path, event_uid) self.caldav.delete_event(user, caldav_path, event_uid)

View File

@@ -38,18 +38,26 @@ class ICSImportService:
def __init__(self): def __init__(self):
self.base_url = settings.CALDAV_URL.rstrip("/") self.base_url = settings.CALDAV_URL.rstrip("/")
def import_events(self, user, calendar, ics_data: bytes) -> ImportResult: def import_events(self, user, caldav_path: str, ics_data: bytes) -> ImportResult:
"""Import events from ICS data into a calendar. """Import events from ICS data into a calendar.
Sends the raw ICS bytes to SabreDAV's ?import endpoint which Sends the raw ICS bytes to SabreDAV's ?import endpoint which
handles all ICS parsing, splitting by UID, VALARM repair, and handles all ICS parsing, splitting by UID, VALARM repair, and
per-event insertion. per-event insertion.
Args:
user: The authenticated user performing the import.
caldav_path: CalDAV path of the calendar
(e.g. /calendars/user@example.com/uuid/).
ics_data: Raw ICS file content.
""" """
result = ImportResult() result = ImportResult()
# caldav_path already includes the base URI prefix # Ensure caldav_path includes the base URI prefix that SabreDAV expects
# e.g. /api/v1.0/caldav/calendars/user@example.com/uuid/ base_uri = "/api/v1.0/caldav/"
url = f"{self.base_url}{calendar.caldav_path}?import" if not caldav_path.startswith(base_uri):
caldav_path = base_uri.rstrip("/") + caldav_path
url = f"{self.base_url}{caldav_path}?import"
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
if not outbound_api_key: if not outbound_api_key:

View File

@@ -23,10 +23,6 @@ def provision_default_calendar(sender, instance, created, **kwargs): # pylint:
if not created: if not created:
return return
# Check if user already has a default calendar
if instance.calendars.filter(is_default=True).exists():
return
# Skip calendar creation if CalDAV server is not configured # Skip calendar creation if CalDAV server is not configured
if not settings.CALDAV_URL: if not settings.CALDAV_URL:
return return

View File

@@ -500,7 +500,7 @@ def test_authentication_session_tokens(
status=200, status=200,
) )
with django_assert_num_queries(7): with django_assert_num_queries(5):
user = klass.authenticate( user = klass.authenticate(
request, request,
code="test-code", code="test-code",

View File

@@ -95,7 +95,7 @@ class TestCalDAVScheduling:
# Create calendar for organizer # Create calendar for organizer
service = CalendarService() service = CalendarService()
calendar = service.create_calendar( caldav_path = service.create_calendar(
organizer, name="Test Calendar", color="#ff0000" organizer, name="Test Calendar", color="#ff0000"
) )
@@ -126,7 +126,7 @@ class TestCalDAVScheduling:
try: try:
# Create an event with an attendee # Create an event with an attendee
client = service.caldav._get_client(organizer) # pylint: disable=protected-access client = service.caldav._get_client(organizer) # pylint: disable=protected-access
calendar_url = f"{settings.CALDAV_URL}{calendar.caldav_path}" calendar_url = f"{settings.CALDAV_URL}{caldav_path}"
# Add custom callback URL header to the client # Add custom callback URL header to the client
# The CalDAV server will use this URL for the callback # The CalDAV server will use this URL for the callback

View File

@@ -59,12 +59,10 @@ class TestCalDAVClient:
user = factories.UserFactory(email="test@example.com") user = factories.UserFactory(email="test@example.com")
service = CalendarService() service = CalendarService()
# Create a calendar # Create a calendar — returns caldav_path string
calendar = service.create_calendar(user, name="My Calendar", color="#ff0000") caldav_path = service.create_calendar(user, name="My Calendar", color="#ff0000")
# Verify calendar was created # Verify caldav_path was returned
assert calendar is not None assert caldav_path is not None
assert calendar.owner == user assert isinstance(caldav_path, str)
assert calendar.name == "My Calendar" assert "calendars/" in caldav_path
assert calendar.color == "#ff0000"
assert calendar.caldav_path is not None

View File

@@ -1,6 +1,7 @@
"""Tests for the ICS import events feature.""" # pylint: disable=too-many-lines """Tests for the ICS import events feature.""" # pylint: disable=too-many-lines
import json import json
import uuid
from datetime import datetime from datetime import datetime
from datetime import timezone as dt_tz from datetime import timezone as dt_tz
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -244,6 +245,11 @@ END:VEVENT
END:VCALENDAR""" END:VCALENDAR"""
def _make_caldav_path(user):
"""Build a caldav_path string for a user (test helper)."""
return f"/calendars/{user.email}/{uuid.uuid4()}/"
def _make_sabredav_response( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments def _make_sabredav_response( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments
status_code=200, status_code=200,
total_events=0, total_events=0,
@@ -278,10 +284,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_SINGLE_EVENT) result = service.import_events(user, caldav_path, ICS_SINGLE_EVENT)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 1 assert result.imported_count == 1
@@ -301,10 +307,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) result = service.import_events(user, caldav_path, ICS_MULTIPLE_EVENTS)
assert result.total_events == 3 assert result.total_events == 3
assert result.imported_count == 3 assert result.imported_count == 3
@@ -321,10 +327,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_EMPTY) result = service.import_events(user, caldav_path, ICS_EMPTY)
assert result.total_events == 0 assert result.total_events == 0
assert result.imported_count == 0 assert result.imported_count == 0
@@ -340,10 +346,10 @@ class TestICSImportService:
mock_post.return_value.text = '{"error": "Failed to parse ICS file"}' mock_post.return_value.text = '{"error": "Failed to parse ICS file"}'
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_INVALID) result = service.import_events(user, caldav_path, ICS_INVALID)
assert result.imported_count == 0 assert result.imported_count == 0
assert len(result.errors) >= 1 assert len(result.errors) >= 1
@@ -356,10 +362,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_WITH_TIMEZONE) result = service.import_events(user, caldav_path, ICS_WITH_TIMEZONE)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 1 assert result.imported_count == 1
@@ -386,10 +392,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) result = service.import_events(user, caldav_path, ICS_MULTIPLE_EVENTS)
assert result.total_events == 3 assert result.total_events == 3
assert result.imported_count == 2 assert result.imported_count == 2
@@ -406,10 +412,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_ALL_DAY_EVENT) result = service.import_events(user, caldav_path, ICS_ALL_DAY_EVENT)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 1 assert result.imported_count == 1
@@ -422,10 +428,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_VALARM_NO_ACTION) result = service.import_events(user, caldav_path, ICS_VALARM_NO_ACTION)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 1 assert result.imported_count == 1
@@ -438,10 +444,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_RECURRING_WITH_EXCEPTION) result = service.import_events(user, caldav_path, ICS_RECURRING_WITH_EXCEPTION)
# Two VEVENTs with same UID = one logical event # Two VEVENTs with same UID = one logical event
assert result.total_events == 1 assert result.total_events == 1
@@ -464,10 +470,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_NO_DTSTART) result = service.import_events(user, caldav_path, ICS_NO_DTSTART)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 0 assert result.imported_count == 0
@@ -476,20 +482,20 @@ class TestICSImportService:
@patch("core.services.import_service.requests.post") @patch("core.services.import_service.requests.post")
def test_import_passes_calendar_path(self, mock_post): def test_import_passes_calendar_path(self, mock_post):
"""The import URL should include the calendar's caldav_path.""" """The import URL should include the caldav_path."""
mock_post.return_value = _make_sabredav_response( mock_post.return_value = _make_sabredav_response(
total_events=1, imported_count=1 total_events=1, imported_count=1
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
service.import_events(user, calendar, ICS_SINGLE_EVENT) service.import_events(user, caldav_path, ICS_SINGLE_EVENT)
call_args = mock_post.call_args call_args = mock_post.call_args
url = call_args.args[0] if call_args.args else call_args.kwargs.get("url", "") url = call_args.args[0] if call_args.args else call_args.kwargs.get("url", "")
assert calendar.caldav_path in url assert caldav_path in url
assert "?import" in url assert "?import" in url
@patch("core.services.import_service.requests.post") @patch("core.services.import_service.requests.post")
@@ -500,10 +506,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
service.import_events(user, calendar, ICS_SINGLE_EVENT) service.import_events(user, caldav_path, ICS_SINGLE_EVENT)
call_kwargs = mock_post.call_args.kwargs call_kwargs = mock_post.call_args.kwargs
headers = call_kwargs["headers"] headers = call_kwargs["headers"]
@@ -524,10 +530,10 @@ class TestICSImportService:
) )
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) result = service.import_events(user, caldav_path, ICS_MULTIPLE_EVENTS)
assert result.total_events == 3 assert result.total_events == 3
assert result.imported_count == 1 assert result.imported_count == 1
@@ -541,10 +547,10 @@ class TestICSImportService:
mock_post.side_effect = req.ConnectionError("Connection refused") mock_post.side_effect = req.ConnectionError("Connection refused")
user = factories.UserFactory() user = factories.UserFactory()
calendar = factories.CalendarFactory(owner=user) caldav_path = _make_caldav_path(user)
service = ICSImportService() service = ICSImportService()
result = service.import_events(user, calendar, ICS_SINGLE_EVENT) result = service.import_events(user, caldav_path, ICS_SINGLE_EVENT)
assert result.imported_count == 0 assert result.imported_count == 0
assert len(result.errors) >= 1 assert len(result.errors) >= 1
@@ -553,23 +559,19 @@ class TestICSImportService:
class TestImportEventsAPI: class TestImportEventsAPI:
"""API endpoint tests for the import_events action.""" """API endpoint tests for the import_events action."""
def _get_url(self, calendar_id): IMPORT_URL = "/api/v1.0/calendars/import-events/"
return f"/api/v1.0/calendars/{calendar_id}/import_events/"
def test_import_events_requires_authentication(self): def test_import_events_requires_authentication(self):
"""Unauthenticated requests should be rejected.""" """Unauthenticated requests should be rejected."""
calendar = factories.CalendarFactory()
client = APIClient() client = APIClient()
response = client.post(self.IMPORT_URL)
response = client.post(self._get_url(calendar.id))
assert response.status_code == 401 assert response.status_code == 401
def test_import_events_forbidden_for_non_owner(self): def test_import_events_forbidden_for_wrong_user(self):
"""Non-owners should not be able to access the calendar.""" """Users cannot import to a calendar they don't own."""
owner = factories.UserFactory() owner = factories.UserFactory(email="owner@example.com")
other_user = factories.UserFactory() other_user = factories.UserFactory(email="other@example.com")
calendar = factories.CalendarFactory(owner=owner) caldav_path = f"/calendars/{owner.email}/some-uuid/"
client = APIClient() client = APIClient()
client.force_login(other_user) client.force_login(other_user)
@@ -578,29 +580,45 @@ class TestImportEventsAPI:
"events.ics", ICS_SINGLE_EVENT, content_type="text/calendar" "events.ics", ICS_SINGLE_EVENT, content_type="text/calendar"
) )
response = client.post( response = client.post(
self._get_url(calendar.id), {"file": ics_file}, format="multipart" self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
) )
assert response.status_code == 403
# Calendar not in queryset for non-owner, so 404 (not 403) def test_import_events_missing_caldav_path(self):
assert response.status_code == 404 """Request without caldav_path should return 400."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
ics_file = SimpleUploadedFile(
"events.ics", ICS_SINGLE_EVENT, content_type="text/calendar"
)
response = client.post(self.IMPORT_URL, {"file": ics_file}, format="multipart")
assert response.status_code == 400
assert "caldav_path" in response.json()["error"]
def test_import_events_missing_file(self): def test_import_events_missing_file(self):
"""Request without a file should return 400.""" """Request without a file should return 400."""
user = factories.UserFactory() user = factories.UserFactory(email="nofile@example.com")
calendar = factories.CalendarFactory(owner=user) caldav_path = f"/calendars/{user.email}/some-uuid/"
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.post(self._get_url(calendar.id), format="multipart") response = client.post(
self.IMPORT_URL,
{"caldav_path": caldav_path},
format="multipart",
)
assert response.status_code == 400 assert response.status_code == 400
assert "No file provided" in response.json()["error"] assert "No file provided" in response.json()["error"]
def test_import_events_file_too_large(self): def test_import_events_file_too_large(self):
"""Files exceeding MAX_FILE_SIZE should be rejected.""" """Files exceeding MAX_FILE_SIZE should be rejected."""
user = factories.UserFactory() user = factories.UserFactory(email="largefile@example.com")
calendar = factories.CalendarFactory(owner=user) caldav_path = f"/calendars/{user.email}/some-uuid/"
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -611,9 +629,10 @@ class TestImportEventsAPI:
content_type="text/calendar", content_type="text/calendar",
) )
response = client.post( response = client.post(
self._get_url(calendar.id), {"file": large_file}, format="multipart" self.IMPORT_URL,
{"file": large_file, "caldav_path": caldav_path},
format="multipart",
) )
assert response.status_code == 400 assert response.status_code == 400
assert "too large" in response.json()["error"] assert "too large" in response.json()["error"]
@@ -628,8 +647,8 @@ class TestImportEventsAPI:
errors=[], errors=[],
) )
user = factories.UserFactory() user = factories.UserFactory(email="success@example.com")
calendar = factories.CalendarFactory(owner=user) caldav_path = f"/calendars/{user.email}/some-uuid/"
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -638,7 +657,9 @@ class TestImportEventsAPI:
"events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar" "events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar"
) )
response = client.post( response = client.post(
self._get_url(calendar.id), {"file": ics_file}, format="multipart" self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -659,8 +680,8 @@ class TestImportEventsAPI:
errors=["Planning session"], errors=["Planning session"],
) )
user = factories.UserFactory() user = factories.UserFactory(email="partial@example.com")
calendar = factories.CalendarFactory(owner=user) caldav_path = f"/calendars/{user.email}/some-uuid/"
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -669,7 +690,9 @@ class TestImportEventsAPI:
"events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar" "events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar"
) )
response = client.post( response = client.post(
self._get_url(calendar.id), {"file": ics_file}, format="multipart" self.IMPORT_URL,
{"file": ics_file, "caldav_path": caldav_path},
format="multipart",
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -688,17 +711,17 @@ class TestImportEventsE2E:
"""End-to-end tests that import ICS events through the real SabreDAV server.""" """End-to-end tests that import ICS events through the real SabreDAV server."""
def _create_calendar(self, user): def _create_calendar(self, user):
"""Create a real calendar in both Django and SabreDAV.""" """Create a real calendar in SabreDAV. Returns the caldav_path."""
service = CalendarService() service = CalendarService()
return service.create_calendar(user, name="Import Test", color="#3174ad") return service.create_calendar(user, name="Import Test", color="#3174ad")
def test_import_single_event_e2e(self): def test_import_single_event_e2e(self):
"""Import a single event and verify it exists in SabreDAV.""" """Import a single event and verify it exists in SabreDAV."""
user = factories.UserFactory(email="import-single@example.com") user = factories.UserFactory(email="import-single@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events(user, calendar, ICS_SINGLE_EVENT) result = import_service.import_events(user, caldav_path, ICS_SINGLE_EVENT)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 1 assert result.imported_count == 1
@@ -709,7 +732,7 @@ class TestImportEventsE2E:
caldav = CalDAVClient() caldav = CalDAVClient()
events = caldav.get_events( events = caldav.get_events(
user, user,
calendar.caldav_path, caldav_path,
start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), start=datetime(2026, 2, 10, tzinfo=dt_tz.utc),
end=datetime(2026, 2, 11, tzinfo=dt_tz.utc), end=datetime(2026, 2, 11, tzinfo=dt_tz.utc),
) )
@@ -720,10 +743,10 @@ class TestImportEventsE2E:
def test_import_multiple_events_e2e(self): def test_import_multiple_events_e2e(self):
"""Import multiple events and verify they all exist in SabreDAV.""" """Import multiple events and verify they all exist in SabreDAV."""
user = factories.UserFactory(email="import-multi@example.com") user = factories.UserFactory(email="import-multi@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) result = import_service.import_events(user, caldav_path, ICS_MULTIPLE_EVENTS)
assert result.total_events == 3 assert result.total_events == 3
assert result.imported_count == 3 assert result.imported_count == 3
@@ -733,7 +756,7 @@ class TestImportEventsE2E:
caldav = CalDAVClient() caldav = CalDAVClient()
events = caldav.get_events( events = caldav.get_events(
user, user,
calendar.caldav_path, caldav_path,
start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), start=datetime(2026, 2, 10, tzinfo=dt_tz.utc),
end=datetime(2026, 2, 12, tzinfo=dt_tz.utc), end=datetime(2026, 2, 12, tzinfo=dt_tz.utc),
) )
@@ -744,10 +767,10 @@ class TestImportEventsE2E:
def test_import_all_day_event_e2e(self): def test_import_all_day_event_e2e(self):
"""Import an all-day event and verify it exists in SabreDAV.""" """Import an all-day event and verify it exists in SabreDAV."""
user = factories.UserFactory(email="import-allday@example.com") user = factories.UserFactory(email="import-allday@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events(user, calendar, ICS_ALL_DAY_EVENT) result = import_service.import_events(user, caldav_path, ICS_ALL_DAY_EVENT)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 1 assert result.imported_count == 1
@@ -757,7 +780,7 @@ class TestImportEventsE2E:
caldav = CalDAVClient() caldav = CalDAVClient()
events = caldav.get_events( events = caldav.get_events(
user, user,
calendar.caldav_path, caldav_path,
start=datetime(2026, 2, 14, tzinfo=dt_tz.utc), start=datetime(2026, 2, 14, tzinfo=dt_tz.utc),
end=datetime(2026, 2, 17, tzinfo=dt_tz.utc), end=datetime(2026, 2, 17, tzinfo=dt_tz.utc),
) )
@@ -767,10 +790,10 @@ class TestImportEventsE2E:
def test_import_with_timezone_e2e(self): def test_import_with_timezone_e2e(self):
"""Import an event with timezone info and verify it in SabreDAV.""" """Import an event with timezone info and verify it in SabreDAV."""
user = factories.UserFactory(email="import-tz@example.com") user = factories.UserFactory(email="import-tz@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events(user, calendar, ICS_WITH_TIMEZONE) result = import_service.import_events(user, caldav_path, ICS_WITH_TIMEZONE)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 1 assert result.imported_count == 1
@@ -780,7 +803,7 @@ class TestImportEventsE2E:
caldav = CalDAVClient() caldav = CalDAVClient()
events = caldav.get_events( events = caldav.get_events(
user, user,
calendar.caldav_path, caldav_path,
start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), start=datetime(2026, 2, 10, tzinfo=dt_tz.utc),
end=datetime(2026, 2, 11, tzinfo=dt_tz.utc), end=datetime(2026, 2, 11, tzinfo=dt_tz.utc),
) )
@@ -790,7 +813,7 @@ class TestImportEventsE2E:
def test_import_via_api_e2e(self): def test_import_via_api_e2e(self):
"""Import events via the API endpoint hitting real SabreDAV.""" """Import events via the API endpoint hitting real SabreDAV."""
user = factories.UserFactory(email="import-api@example.com") user = factories.UserFactory(email="import-api@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -799,8 +822,8 @@ class TestImportEventsE2E:
"events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar" "events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar"
) )
response = client.post( response = client.post(
f"/api/v1.0/calendars/{calendar.id}/import_events/", "/api/v1.0/calendars/import-events/",
{"file": ics_file}, {"file": ics_file, "caldav_path": caldav_path},
format="multipart", format="multipart",
) )
@@ -814,7 +837,7 @@ class TestImportEventsE2E:
caldav = CalDAVClient() caldav = CalDAVClient()
events = caldav.get_events( events = caldav.get_events(
user, user,
calendar.caldav_path, caldav_path,
start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), start=datetime(2026, 2, 10, tzinfo=dt_tz.utc),
end=datetime(2026, 2, 12, tzinfo=dt_tz.utc), end=datetime(2026, 2, 12, tzinfo=dt_tz.utc),
) )
@@ -828,11 +851,11 @@ class TestImportEventsE2E:
plugin used the wrong callback signature for that event. plugin used the wrong callback signature for that event.
""" """
user = factories.UserFactory(email="import-attendee@example.com") user = factories.UserFactory(email="import-attendee@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
# Import event with attendees # Import event with attendees
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events(user, calendar, ICS_WITH_ATTENDEES) result = import_service.import_events(user, caldav_path, ICS_WITH_ATTENDEES)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 1 assert result.imported_count == 1
@@ -842,7 +865,7 @@ class TestImportEventsE2E:
caldav = CalDAVClient() caldav = CalDAVClient()
caldav.update_event( caldav.update_event(
user, user,
calendar.caldav_path, caldav_path,
"attendee-event-1", "attendee-event-1",
{"title": "Updated review meeting"}, {"title": "Updated review meeting"},
) )
@@ -850,7 +873,7 @@ class TestImportEventsE2E:
# Verify update was applied # Verify update was applied
events = caldav.get_events( events = caldav.get_events(
user, user,
calendar.caldav_path, caldav_path,
start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), start=datetime(2026, 2, 10, tzinfo=dt_tz.utc),
end=datetime(2026, 2, 11, tzinfo=dt_tz.utc), end=datetime(2026, 2, 11, tzinfo=dt_tz.utc),
) )
@@ -865,11 +888,11 @@ class TestImportEventsE2E:
binds values as PARAM_STR instead of PARAM_LOB. binds values as PARAM_STR instead of PARAM_LOB.
""" """
user = factories.UserFactory(email="import-escapes@example.com") user = factories.UserFactory(email="import-escapes@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events( result = import_service.import_events(
user, calendar, ICS_WITH_NEWLINES_IN_DESCRIPTION user, caldav_path, ICS_WITH_NEWLINES_IN_DESCRIPTION
) )
assert result.total_events == 1 assert result.total_events == 1
@@ -880,7 +903,7 @@ class TestImportEventsE2E:
caldav = CalDAVClient() caldav = CalDAVClient()
events = caldav.get_events( events = caldav.get_events(
user, user,
calendar.caldav_path, caldav_path,
start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), start=datetime(2026, 2, 10, tzinfo=dt_tz.utc),
end=datetime(2026, 2, 11, tzinfo=dt_tz.utc), end=datetime(2026, 2, 11, tzinfo=dt_tz.utc),
) )
@@ -890,17 +913,17 @@ class TestImportEventsE2E:
def test_import_same_file_twice_no_duplicates_e2e(self): def test_import_same_file_twice_no_duplicates_e2e(self):
"""Importing the same ICS file twice should not create duplicates.""" """Importing the same ICS file twice should not create duplicates."""
user = factories.UserFactory(email="import-dedup@example.com") user = factories.UserFactory(email="import-dedup@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
# First import # First import
result1 = import_service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) result1 = import_service.import_events(user, caldav_path, ICS_MULTIPLE_EVENTS)
assert result1.imported_count == 3 assert result1.imported_count == 3
assert not result1.errors assert not result1.errors
# Second import of the same file — all should be duplicates # Second import of the same file — all should be duplicates
result2 = import_service.import_events(user, calendar, ICS_MULTIPLE_EVENTS) result2 = import_service.import_events(user, caldav_path, ICS_MULTIPLE_EVENTS)
assert result2.duplicate_count == 3 assert result2.duplicate_count == 3
assert result2.imported_count == 0 assert result2.imported_count == 0
assert result2.skipped_count == 0 assert result2.skipped_count == 0
@@ -909,7 +932,7 @@ class TestImportEventsE2E:
caldav = CalDAVClient() caldav = CalDAVClient()
events = caldav.get_events( events = caldav.get_events(
user, user,
calendar.caldav_path, caldav_path,
start=datetime(2026, 2, 10, tzinfo=dt_tz.utc), start=datetime(2026, 2, 10, tzinfo=dt_tz.utc),
end=datetime(2026, 2, 12, tzinfo=dt_tz.utc), end=datetime(2026, 2, 12, tzinfo=dt_tz.utc),
) )
@@ -918,21 +941,21 @@ class TestImportEventsE2E:
def test_import_dead_recurring_event_skipped_silently_e2e(self): def test_import_dead_recurring_event_skipped_silently_e2e(self):
"""A recurring event whose EXDATE excludes all instances is skipped, not an error.""" """A recurring event whose EXDATE excludes all instances is skipped, not an error."""
user = factories.UserFactory(email="import-dead-recur@example.com") user = factories.UserFactory(email="import-dead-recur@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events(user, calendar, ICS_DEAD_RECURRING) result = import_service.import_events(user, caldav_path, ICS_DEAD_RECURRING)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 0 assert result.imported_count == 0
assert result.skipped_count == 1 assert result.skipped_count == 1
assert not result.errors assert not result.errors
def _get_raw_event(self, user, calendar, uid): def _get_raw_event(self, user, caldav_path, uid):
"""Fetch the raw ICS data of a single event from SabreDAV by UID.""" """Fetch the raw ICS data of a single event from SabreDAV by UID."""
caldav_client = CalDAVClient() caldav_client = CalDAVClient()
client = caldav_client._get_client(user) # pylint: disable=protected-access client = caldav_client._get_client(user) # pylint: disable=protected-access
cal_url = f"{caldav_client.base_url}{calendar.caldav_path}" cal_url = f"{caldav_client.base_url}{caldav_path}"
cal = client.calendar(url=cal_url) cal = client.calendar(url=cal_url)
event = cal.event_by_uid(uid) event = cal.event_by_uid(uid)
return event.data return event.data
@@ -940,11 +963,11 @@ class TestImportEventsE2E:
def test_import_strips_binary_attachments_e2e(self): def test_import_strips_binary_attachments_e2e(self):
"""Binary attachments should be stripped during import.""" """Binary attachments should be stripped during import."""
user = factories.UserFactory(email="import-strip-attach@example.com") user = factories.UserFactory(email="import-strip-attach@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events( result = import_service.import_events(
user, calendar, ICS_WITH_BINARY_ATTACHMENT user, caldav_path, ICS_WITH_BINARY_ATTACHMENT
) )
assert result.total_events == 1 assert result.total_events == 1
@@ -952,7 +975,7 @@ class TestImportEventsE2E:
assert not result.errors assert not result.errors
# Verify event exists and binary attachment was stripped # Verify event exists and binary attachment was stripped
raw = self._get_raw_event(user, calendar, "attach-binary-1") raw = self._get_raw_event(user, caldav_path, "attach-binary-1")
assert "Event with inline attachment" in raw assert "Event with inline attachment" in raw
assert "iVBORw0KGgo" not in raw assert "iVBORw0KGgo" not in raw
assert "ATTACH" not in raw assert "ATTACH" not in raw
@@ -960,28 +983,30 @@ class TestImportEventsE2E:
def test_import_keeps_url_attachments_e2e(self): def test_import_keeps_url_attachments_e2e(self):
"""URL-based attachments should NOT be stripped during import.""" """URL-based attachments should NOT be stripped during import."""
user = factories.UserFactory(email="import-keep-url-attach@example.com") user = factories.UserFactory(email="import-keep-url-attach@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events(user, calendar, ICS_WITH_URL_ATTACHMENT) result = import_service.import_events(
user, caldav_path, ICS_WITH_URL_ATTACHMENT
)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 1 assert result.imported_count == 1
assert not result.errors assert not result.errors
# Verify URL attachment is preserved in raw ICS # Verify URL attachment is preserved in raw ICS
raw = self._get_raw_event(user, calendar, "attach-url-1") raw = self._get_raw_event(user, caldav_path, "attach-url-1")
assert "https://example.com/doc.pdf" in raw assert "https://example.com/doc.pdf" in raw
assert "ATTACH" in raw assert "ATTACH" in raw
def test_import_truncates_large_description_e2e(self): def test_import_truncates_large_description_e2e(self):
"""Descriptions exceeding IMPORT_MAX_DESCRIPTION_BYTES should be truncated.""" """Descriptions exceeding IMPORT_MAX_DESCRIPTION_BYTES should be truncated."""
user = factories.UserFactory(email="import-trunc-desc@example.com") user = factories.UserFactory(email="import-trunc-desc@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events( result = import_service.import_events(
user, calendar, ICS_WITH_LARGE_DESCRIPTION user, caldav_path, ICS_WITH_LARGE_DESCRIPTION
) )
assert result.total_events == 1 assert result.total_events == 1
@@ -989,7 +1014,7 @@ class TestImportEventsE2E:
assert not result.errors assert not result.errors
# Verify description was truncated (default 100KB limit, original 200KB) # Verify description was truncated (default 100KB limit, original 200KB)
raw = self._get_raw_event(user, calendar, "large-desc-1") raw = self._get_raw_event(user, caldav_path, "large-desc-1")
assert "Event with huge description" in raw assert "Event with huge description" in raw
# Raw ICS should be much smaller than the 200KB original # Raw ICS should be much smaller than the 200KB original
assert len(raw) < 150000 assert len(raw) < 150000
@@ -1005,15 +1030,15 @@ class TestCalendarSanitizerE2E:
"""E2E tests for CalendarSanitizerPlugin on normal CalDAV PUT operations.""" """E2E tests for CalendarSanitizerPlugin on normal CalDAV PUT operations."""
def _create_calendar(self, user): def _create_calendar(self, user):
"""Create a real calendar in both Django and SabreDAV.""" """Create a real calendar in SabreDAV. Returns the caldav_path."""
service = CalendarService() service = CalendarService()
return service.create_calendar(user, name="Sanitizer Test", color="#3174ad") return service.create_calendar(user, name="Sanitizer Test", color="#3174ad")
def _get_raw_event(self, user, calendar, uid): def _get_raw_event(self, user, caldav_path, uid):
"""Fetch the raw ICS data of a single event from SabreDAV by UID.""" """Fetch the raw ICS data of a single event from SabreDAV by UID."""
caldav_client = CalDAVClient() caldav_client = CalDAVClient()
client = caldav_client._get_client(user) # pylint: disable=protected-access client = caldav_client._get_client(user) # pylint: disable=protected-access
cal_url = f"{caldav_client.base_url}{calendar.caldav_path}" cal_url = f"{caldav_client.base_url}{caldav_path}"
cal = client.calendar(url=cal_url) cal = client.calendar(url=cal_url)
event = cal.event_by_uid(uid) event = cal.event_by_uid(uid)
return event.data return event.data
@@ -1021,14 +1046,12 @@ class TestCalendarSanitizerE2E:
def test_caldav_put_strips_binary_attachment_e2e(self): def test_caldav_put_strips_binary_attachment_e2e(self):
"""A normal CalDAV PUT with binary attachment should be sanitized.""" """A normal CalDAV PUT with binary attachment should be sanitized."""
user = factories.UserFactory(email="sanitizer-put-attach@example.com") user = factories.UserFactory(email="sanitizer-put-attach@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
caldav = CalDAVClient() caldav = CalDAVClient()
caldav.create_event_raw( caldav.create_event_raw(user, caldav_path, ICS_WITH_BINARY_ATTACHMENT.decode())
user, calendar.caldav_path, ICS_WITH_BINARY_ATTACHMENT.decode()
)
raw = self._get_raw_event(user, calendar, "attach-binary-1") raw = self._get_raw_event(user, caldav_path, "attach-binary-1")
assert "Event with inline attachment" in raw assert "Event with inline attachment" in raw
assert "iVBORw0KGgo" not in raw assert "iVBORw0KGgo" not in raw
assert "ATTACH" not in raw assert "ATTACH" not in raw
@@ -1036,28 +1059,24 @@ class TestCalendarSanitizerE2E:
def test_caldav_put_keeps_url_attachment_e2e(self): def test_caldav_put_keeps_url_attachment_e2e(self):
"""A normal CalDAV PUT with URL attachment should preserve it.""" """A normal CalDAV PUT with URL attachment should preserve it."""
user = factories.UserFactory(email="sanitizer-put-url@example.com") user = factories.UserFactory(email="sanitizer-put-url@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
caldav = CalDAVClient() caldav = CalDAVClient()
caldav.create_event_raw( caldav.create_event_raw(user, caldav_path, ICS_WITH_URL_ATTACHMENT.decode())
user, calendar.caldav_path, ICS_WITH_URL_ATTACHMENT.decode()
)
raw = self._get_raw_event(user, calendar, "attach-url-1") raw = self._get_raw_event(user, caldav_path, "attach-url-1")
assert "https://example.com/doc.pdf" in raw assert "https://example.com/doc.pdf" in raw
assert "ATTACH" in raw assert "ATTACH" in raw
def test_caldav_put_truncates_large_description_e2e(self): def test_caldav_put_truncates_large_description_e2e(self):
"""A normal CalDAV PUT with oversized description should be truncated.""" """A normal CalDAV PUT with oversized description should be truncated."""
user = factories.UserFactory(email="sanitizer-put-desc@example.com") user = factories.UserFactory(email="sanitizer-put-desc@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
caldav = CalDAVClient() caldav = CalDAVClient()
caldav.create_event_raw( caldav.create_event_raw(user, caldav_path, ICS_WITH_LARGE_DESCRIPTION.decode())
user, calendar.caldav_path, ICS_WITH_LARGE_DESCRIPTION.decode()
)
raw = self._get_raw_event(user, calendar, "large-desc-1") raw = self._get_raw_event(user, caldav_path, "large-desc-1")
assert "Event with huge description" in raw assert "Event with huge description" in raw
assert len(raw) < 150000 assert len(raw) < 150000
assert "..." in raw assert "..." in raw
@@ -1065,23 +1084,21 @@ class TestCalendarSanitizerE2E:
def test_caldav_put_rejects_oversized_event_e2e(self): def test_caldav_put_rejects_oversized_event_e2e(self):
"""A CalDAV PUT exceeding max-resource-size should be rejected (HTTP 507).""" """A CalDAV PUT exceeding max-resource-size should be rejected (HTTP 507)."""
user = factories.UserFactory(email="sanitizer-put-oversize@example.com") user = factories.UserFactory(email="sanitizer-put-oversize@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
caldav = CalDAVClient() caldav = CalDAVClient()
with pytest.raises(Exception) as exc_info: with pytest.raises(Exception) as exc_info:
caldav.create_event_raw( caldav.create_event_raw(user, caldav_path, ICS_OVERSIZED_EVENT.decode())
user, calendar.caldav_path, ICS_OVERSIZED_EVENT.decode()
)
# SabreDAV returns 507 Insufficient Storage # SabreDAV returns 507 Insufficient Storage
assert "507" in str(exc_info.value) or "Insufficient" in str(exc_info.value) assert "507" in str(exc_info.value) or "Insufficient" in str(exc_info.value)
def test_import_rejects_oversized_event_e2e(self): def test_import_rejects_oversized_event_e2e(self):
"""Import of an event exceeding max-resource-size should skip it.""" """Import of an event exceeding max-resource-size should skip it."""
user = factories.UserFactory(email="sanitizer-import-oversize@example.com") user = factories.UserFactory(email="sanitizer-import-oversize@example.com")
calendar = self._create_calendar(user) caldav_path = self._create_calendar(user)
import_service = ICSImportService() import_service = ICSImportService()
result = import_service.import_events(user, calendar, ICS_OVERSIZED_EVENT) result = import_service.import_events(user, caldav_path, ICS_OVERSIZED_EVENT)
assert result.total_events == 1 assert result.total_events == 1
assert result.imported_count == 0 assert result.imported_count == 0

View File

@@ -4,46 +4,14 @@
import { fetchAPI, fetchAPIFormData } from "@/features/api/fetchApi"; import { fetchAPI, fetchAPIFormData } from "@/features/api/fetchApi";
export interface Calendar {
id: string;
name: string;
color: string;
description: string;
is_default: boolean;
is_visible: boolean;
caldav_path: string;
owner: string;
}
/** /**
* Paginated API response. * Create a new calendar via Django API (CalDAV only).
*/
interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
/**
* Fetch all calendars accessible by the current user.
*/
export const getCalendars = async (): Promise<Calendar[]> => {
const response = await fetchAPI("calendars/");
const data: PaginatedResponse<Calendar> = await response.json();
return data.results;
};
/**
* Create a new calendar via Django API.
* This creates both the CalDAV calendar and the Django record.
*/ */
export const createCalendarApi = async (data: { export const createCalendarApi = async (data: {
name: string; name: string;
color?: string; color?: string;
description?: string; description?: string;
}): Promise<Calendar> => { }): Promise<{ caldav_path: string }> => {
const response = await fetchAPI("calendars/", { const response = await fetchAPI("calendars/", {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -51,41 +19,6 @@ export const createCalendarApi = async (data: {
return response.json(); return response.json();
}; };
/**
* Update an existing calendar via Django API.
*/
export const updateCalendarApi = async (
calendarId: string,
data: { name?: string; color?: string; description?: string }
): Promise<Calendar> => {
const response = await fetchAPI(`calendars/${calendarId}/`, {
method: "PATCH",
body: JSON.stringify(data),
});
return response.json();
};
/**
* Delete a calendar via Django API.
*/
export const deleteCalendarApi = async (calendarId: string): Promise<void> => {
await fetchAPI(`calendars/${calendarId}/`, {
method: "DELETE",
});
};
/**
* Toggle calendar visibility.
*/
export const toggleCalendarVisibility = async (
calendarId: string
): Promise<{ is_visible: boolean }> => {
const response = await fetchAPI(`calendars/${calendarId}/toggle_visibility/`, {
method: "PATCH",
});
return response.json();
};
/** /**
* Subscription token for iCal export. * Subscription token for iCal export.
*/ */
@@ -220,14 +153,15 @@ export interface ImportEventsResult {
* Import events from an ICS file into a calendar. * Import events from an ICS file into a calendar.
*/ */
export const importEventsApi = async ( export const importEventsApi = async (
calendarId: string, caldavPath: string,
file: File, file: File,
): Promise<ImportEventsResult> => { ): Promise<ImportEventsResult> => {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("caldav_path", caldavPath);
const response = await fetchAPIFormData( const response = await fetchAPIFormData(
`calendars/${calendarId}/import_events/`, "calendars/import-events/",
{ {
method: "POST", method: "POST",
body: formData, body: formData,
@@ -235,4 +169,3 @@ export const importEventsApi = async (
); );
return response.json(); return response.json();
}; };

View File

@@ -2,26 +2,22 @@
* CalendarList component - List of calendars with visibility toggles. * CalendarList component - List of calendars with visibility toggles.
*/ */
import { useState, useMemo, useCallback } from "react"; import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import type { Calendar } from "../../types";
import { useCalendarContext } from "../../contexts"; import { useCalendarContext } from "../../contexts";
import { CalendarModal } from "./CalendarModal"; import { CalendarModal } from "./CalendarModal";
import { DeleteConfirmModal } from "./DeleteConfirmModal"; import { DeleteConfirmModal } from "./DeleteConfirmModal";
import { ImportEventsModal } from "./ImportEventsModal"; import { ImportEventsModal } from "./ImportEventsModal";
import { SubscriptionUrlModal } from "./SubscriptionUrlModal"; import { SubscriptionUrlModal } from "./SubscriptionUrlModal";
import { CalendarListItem, SharedCalendarListItem } from "./CalendarListItem"; import { CalendarListItem } from "./CalendarListItem";
import { useCalendarListState } from "./hooks/useCalendarListState"; import { useCalendarListState } from "./hooks/useCalendarListState";
import type { CalendarListProps } from "./types";
import type { CalDavCalendar } from "../../services/dav/types/caldav-service"; import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
import { Calendar as DjangoCalendar, getCalendars } from "../../api"; import { extractCaldavPath } from "./utils";
export const CalendarList = ({ calendars }: CalendarListProps) => { export const CalendarList = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const { const {
davCalendars, davCalendars,
visibleCalendarUrls, visibleCalendarUrls,
@@ -37,7 +33,6 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
modalState, modalState,
deleteState, deleteState,
isMyCalendarsExpanded, isMyCalendarsExpanded,
isSharedCalendarsExpanded,
openMenuUrl, openMenuUrl,
handleOpenCreateModal, handleOpenCreateModal,
handleOpenEditModal, handleOpenEditModal,
@@ -50,7 +45,6 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
handleMenuToggle, handleMenuToggle,
handleCloseMenu, handleCloseMenu,
handleToggleMyCalendars, handleToggleMyCalendars,
handleToggleSharedCalendars,
} = useCalendarListState({ } = useCalendarListState({
createCalendar, createCalendar,
updateCalendar, updateCalendar,
@@ -66,107 +60,42 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
}>({ isOpen: false, calendarName: "", caldavPath: null }); }>({ isOpen: false, calendarName: "", caldavPath: null });
const handleOpenSubscriptionModal = (davCalendar: CalDavCalendar) => { const handleOpenSubscriptionModal = (davCalendar: CalDavCalendar) => {
try { const caldavPath = extractCaldavPath(davCalendar.url);
// Extract the CalDAV path from the calendar URL if (!caldavPath) {
// URL format: http://localhost:8921/api/v1.0/caldav/calendars/user@example.com/uuid/ return;
const url = new URL(davCalendar.url);
const pathParts = url.pathname.split("/").filter(Boolean);
// Find the index of "calendars" and extract from there
const calendarsIndex = pathParts.findIndex((part) => part === "calendars");
if (calendarsIndex === -1) {
console.error("Invalid calendar URL format - 'calendars' segment not found:", davCalendar.url);
// Reset modal state to avoid stale data
setSubscriptionModal({ isOpen: false, calendarName: "", caldavPath: null });
return;
}
// Validate that we have enough parts for a valid path: calendars/email/uuid
const remainingParts = pathParts.slice(calendarsIndex);
if (remainingParts.length < 3) {
console.error("Invalid calendar URL format - incomplete path:", davCalendar.url);
// Reset modal state to avoid stale data
setSubscriptionModal({ isOpen: false, calendarName: "", caldavPath: null });
return;
}
// Ensure trailing slash for consistency with backend expectations
const caldavPath = "/" + remainingParts.join("/") + "/";
setSubscriptionModal({
isOpen: true,
calendarName: davCalendar.displayName || "",
caldavPath: caldavPath,
});
} catch (error) {
console.error("Failed to parse calendar URL:", error);
// Reset modal state on error
setSubscriptionModal({ isOpen: false, calendarName: "", caldavPath: null });
} }
setSubscriptionModal({
isOpen: true,
calendarName: davCalendar.displayName || "",
caldavPath,
});
}; };
const handleCloseSubscriptionModal = () => { const handleCloseSubscriptionModal = () => {
setSubscriptionModal({ isOpen: false, calendarName: "", caldavPath: null }); setSubscriptionModal({ isOpen: false, calendarName: "", caldavPath: null });
}; };
// Ensure calendars is an array
const calendarsArray = Array.isArray(calendars) ? calendars : [];
// Import modal state // Import modal state
const [importModal, setImportModal] = useState<{ const [importModal, setImportModal] = useState<{
isOpen: boolean; isOpen: boolean;
calendarId: string | null; caldavPath: string | null;
calendarName: string; calendarName: string;
}>({ isOpen: false, calendarId: null, calendarName: "" }); }>({ isOpen: false, caldavPath: null, calendarName: "" });
const handleOpenImportModal = async (davCalendar: CalDavCalendar) => { const handleOpenImportModal = (davCalendar: CalDavCalendar) => {
try { const caldavPath = extractCaldavPath(davCalendar.url);
// Extract the CalDAV path from the calendar URL if (!caldavPath) {
const url = new URL(davCalendar.url); return;
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);
} }
setImportModal({
isOpen: true,
caldavPath,
calendarName: davCalendar.displayName || "",
});
}; };
const handleCloseImportModal = () => { const handleCloseImportModal = () => {
setImportModal({ isOpen: false, calendarId: null, calendarName: "" }); setImportModal({ isOpen: false, caldavPath: null, calendarName: "" });
}; };
const handleImportSuccess = useCallback(() => { const handleImportSuccess = useCallback(() => {
@@ -175,15 +104,6 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
} }
}, [calendarRef]); }, [calendarRef]);
// Use translation key for shared marker
const sharedMarker = t('calendar.list.shared');
// Memoize filtered calendars to avoid recalculation on every render
const sharedCalendars = useMemo(
() => calendarsArray.filter((cal) => cal.name.includes(sharedMarker)),
[calendarsArray, sharedMarker]
);
return ( return (
<> <>
<div className="calendar-list"> <div className="calendar-list">
@@ -235,43 +155,6 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
</div> </div>
)} )}
</div> </div>
{sharedCalendars.length > 0 && (
<div className="calendar-list__section">
<div className="calendar-list__section-header">
<button
className="calendar-list__toggle-btn"
onClick={handleToggleSharedCalendars}
aria-expanded={isSharedCalendarsExpanded}
>
<span
className={`material-icons calendar-list__toggle-icon ${
isSharedCalendarsExpanded
? 'calendar-list__toggle-icon--expanded'
: ''
}`}
>
expand_more
</span>
<span className="calendar-list__section-title">
{t('calendar.list.sharedCalendars')}
</span>
</button>
</div>
{isSharedCalendarsExpanded && (
<div className="calendar-list__items">
{sharedCalendars.map((calendar: Calendar) => (
<SharedCalendarListItem
key={calendar.id}
calendar={calendar}
isVisible={visibleCalendarUrls.has(String(calendar.id))}
onToggleVisibility={toggleCalendarVisibility}
/>
))}
</div>
)}
</div>
)}
</div> </div>
<CalendarModal <CalendarModal
@@ -300,10 +183,10 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
/> />
)} )}
{importModal.isOpen && importModal.calendarId && ( {importModal.isOpen && importModal.caldavPath && (
<ImportEventsModal <ImportEventsModal
isOpen={importModal.isOpen} isOpen={importModal.isOpen}
calendarId={importModal.calendarId} caldavPath={importModal.caldavPath}
calendarName={importModal.calendarName} calendarName={importModal.calendarName}
onClose={handleCloseImportModal} onClose={handleCloseImportModal}
onImportSuccess={handleImportSuccess} onImportSuccess={handleImportSuccess}

View File

@@ -7,10 +7,7 @@ import { useTranslation } from "react-i18next";
import { Checkbox } from "@gouvfr-lasuite/cunningham-react"; import { Checkbox } from "@gouvfr-lasuite/cunningham-react";
import { CalendarItemMenu } from "./CalendarItemMenu"; import { CalendarItemMenu } from "./CalendarItemMenu";
import type { import type { CalendarListItemProps } from "./types";
CalendarListItemProps,
SharedCalendarListItemProps,
} from "./types";
/** /**
* CalendarListItem - Displays a user-owned calendar. * CalendarListItem - Displays a user-owned calendar.
@@ -68,36 +65,3 @@ export const CalendarListItem = ({
); );
}; };
/**
* SharedCalendarListItem - Displays a shared calendar.
*/
export const SharedCalendarListItem = ({
calendar,
isVisible,
onToggleVisibility,
}: SharedCalendarListItemProps) => {
const { t } = useTranslation();
return (
<div className="calendar-list__item">
<div
className="calendar-list__item-checkbox"
style={{ "--calendar-color": calendar.color } as React.CSSProperties}
>
<Checkbox
checked={isVisible}
onChange={() => onToggleVisibility(String(calendar.id))}
label=""
aria-label={`${t("calendar.list.showCalendar")} ${calendar.name}`}
/>
<span
className="calendar-list__color"
style={{ backgroundColor: calendar.color }}
/>
</div>
<span className="calendar-list__name" title={calendar.name}>
{calendar.name}
</span>
</div>
);
};

View File

@@ -13,7 +13,7 @@ import type { ImportEventsResult } from "../../api";
interface ImportEventsModalProps { interface ImportEventsModalProps {
isOpen: boolean; isOpen: boolean;
calendarId: string; caldavPath: string;
calendarName: string; calendarName: string;
onClose: () => void; onClose: () => void;
onImportSuccess?: () => void; onImportSuccess?: () => void;
@@ -21,7 +21,7 @@ interface ImportEventsModalProps {
export const ImportEventsModal = ({ export const ImportEventsModal = ({
isOpen, isOpen,
calendarId, caldavPath,
calendarName, calendarName,
onClose, onClose,
onImportSuccess, onImportSuccess,
@@ -42,7 +42,7 @@ export const ImportEventsModal = ({
if (!selectedFile) return; if (!selectedFile) return;
const importResult = await importMutation.mutateAsync({ const importResult = await importMutation.mutateAsync({
calendarId, caldavPath,
file: selectedFile, file: selectedFile,
}); });
setResult(importResult); setResult(importResult);

View File

@@ -47,8 +47,6 @@ export const useCalendarListState = ({
}); });
const [isMyCalendarsExpanded, setIsMyCalendarsExpanded] = useState(true); const [isMyCalendarsExpanded, setIsMyCalendarsExpanded] = useState(true);
const [isSharedCalendarsExpanded, setIsSharedCalendarsExpanded] =
useState(true);
const [openMenuUrl, setOpenMenuUrl] = useState<string | null>(null); const [openMenuUrl, setOpenMenuUrl] = useState<string | null>(null);
// Modal handlers // Modal handlers
@@ -162,10 +160,6 @@ export const useCalendarListState = ({
setIsMyCalendarsExpanded((prev) => !prev); setIsMyCalendarsExpanded((prev) => !prev);
}, []); }, []);
const handleToggleSharedCalendars = useCallback(() => {
setIsSharedCalendarsExpanded((prev) => !prev);
}, []);
return { return {
// Modal state // Modal state
modalState, modalState,
@@ -173,7 +167,6 @@ export const useCalendarListState = ({
// Expansion state // Expansion state
isMyCalendarsExpanded, isMyCalendarsExpanded,
isSharedCalendarsExpanded,
openMenuUrl, openMenuUrl,
// Modal handlers // Modal handlers
@@ -194,6 +187,5 @@ export const useCalendarListState = ({
// Expansion handlers // Expansion handlers
handleToggleMyCalendars, handleToggleMyCalendars,
handleToggleSharedCalendars,
}; };
}; };

View File

@@ -7,7 +7,7 @@ export { CalendarModal } from "./CalendarModal";
export { CalendarItemMenu } from "./CalendarItemMenu"; export { CalendarItemMenu } from "./CalendarItemMenu";
export { DeleteConfirmModal } from "./DeleteConfirmModal"; export { DeleteConfirmModal } from "./DeleteConfirmModal";
export { SubscriptionUrlModal } from "./SubscriptionUrlModal"; export { SubscriptionUrlModal } from "./SubscriptionUrlModal";
export { CalendarListItem, SharedCalendarListItem } from "./CalendarListItem"; export { CalendarListItem } from "./CalendarListItem";
export { useCalendarListState } from "./hooks/useCalendarListState"; export { useCalendarListState } from "./hooks/useCalendarListState";
export { DEFAULT_COLORS } from "./constants"; export { DEFAULT_COLORS } from "./constants";
export * from "./types"; export * from "./types";

View File

@@ -3,7 +3,6 @@
*/ */
import type { CalDavCalendar } from "../../services/dav/types/caldav-service"; import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
import type { Calendar } from "../../types";
/** /**
* Props for the CalendarModal component. * Props for the CalendarModal component.
@@ -56,22 +55,6 @@ export interface CalendarListItemProps {
onCloseMenu: () => void; onCloseMenu: () => void;
} }
/**
* Props for the SharedCalendarListItem component.
*/
export interface SharedCalendarListItemProps {
calendar: Calendar;
isVisible: boolean;
onToggleVisibility: (id: string) => void;
}
/**
* Props for the main CalendarList component.
*/
export interface CalendarListProps {
calendars: Calendar[];
}
/** /**
* State for the calendar modal. * State for the calendar modal.
*/ */

View File

@@ -0,0 +1,38 @@
/**
* Extract the CalDAV path from a full calendar URL.
*
* URL format: http://localhost:8921/api/v1.0/caldav/calendars/user@example.com/uuid/
* Returns: /calendars/user@example.com/uuid/
*/
export const extractCaldavPath = (calendarUrl: string): string | null => {
try {
const url = new URL(calendarUrl);
const pathParts = url.pathname.split("/").filter(Boolean);
const calendarsIndex = pathParts.findIndex(
(part) => part === "calendars",
);
if (calendarsIndex === -1) {
console.error(
"Invalid calendar URL format - 'calendars' segment not found:",
calendarUrl,
);
return null;
}
const remainingParts = pathParts.slice(calendarsIndex);
if (remainingParts.length < 3) {
console.error(
"Invalid calendar URL format - incomplete path:",
calendarUrl,
);
return null;
}
return "/" + remainingParts.join("/") + "/";
} catch (error) {
console.error("Failed to parse calendar URL:", error);
return null;
}
};

View File

@@ -11,7 +11,6 @@ import { CalendarList } from "../calendar-list";
import { MiniCalendar } from "./MiniCalendar"; import { MiniCalendar } from "./MiniCalendar";
import { EventModal } from "../scheduler/EventModal"; import { EventModal } from "../scheduler/EventModal";
import { useCalendarContext } from "../../contexts"; import { useCalendarContext } from "../../contexts";
import { useCalendars } from "../../hooks/useCalendars";
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -44,8 +43,6 @@ export const LeftPanel = () => {
calendarRef, calendarRef,
} = useCalendarContext(); } = useCalendarContext();
const { data: calendars = [] } = useCalendars();
// Get default calendar URL // Get default calendar URL
const defaultCalendarUrl = davCalendars[0]?.url || ""; const defaultCalendarUrl = davCalendars[0]?.url || "";
@@ -140,7 +137,7 @@ export const LeftPanel = () => {
<div className="calendar-left-panel__divider" /> <div className="calendar-left-panel__divider" />
<CalendarList calendars={calendars} /> <CalendarList />
</div> </div>
{modal.isOpen && ( {modal.isOpen && (

View File

@@ -18,6 +18,31 @@ import type {
import type { CalendarApi } from "../components/scheduler/types"; import type { CalendarApi } from "../components/scheduler/types";
import { createCalendarApi } from "../api"; import { createCalendarApi } from "../api";
const HIDDEN_CALENDARS_KEY = "calendar-hidden-urls";
const loadHiddenUrls = (): Set<string> => {
try {
const stored = localStorage.getItem(HIDDEN_CALENDARS_KEY);
if (stored) {
return new Set(JSON.parse(stored) as string[]);
}
} catch {
// Ignore parse errors
}
return new Set();
};
const saveHiddenUrls = (hiddenUrls: Set<string>) => {
try {
localStorage.setItem(
HIDDEN_CALENDARS_KEY,
JSON.stringify([...hiddenUrls]),
);
} catch {
// Ignore storage errors
}
};
export interface CalendarContextType { export interface CalendarContextType {
calendarRef: React.RefObject<CalendarApi | null>; calendarRef: React.RefObject<CalendarApi | null>;
caldavService: CalDavService; caldavService: CalDavService;
@@ -88,8 +113,11 @@ export const CalendarContextProvider = ({
const result = await caldavService.fetchCalendars(); const result = await caldavService.fetchCalendars();
if (result.success && result.data) { if (result.success && result.data) {
setDavCalendars(result.data); setDavCalendars(result.data);
// Initialize all calendars as visible // Compute visible = all minus hidden (new calendars default to visible)
setVisibleCalendarUrls(new Set(result.data.map((cal) => cal.url))); const hidden = loadHiddenUrls();
setVisibleCalendarUrls(
new Set(result.data.map((cal) => cal.url).filter((url) => !hidden.has(url))),
);
} else { } else {
console.error("Error fetching calendars:", result.error); console.error("Error fetching calendars:", result.error);
setDavCalendars([]); setDavCalendars([]);
@@ -106,22 +134,26 @@ export const CalendarContextProvider = ({
const toggleCalendarVisibility = useCallback((calendarUrl: string) => { const toggleCalendarVisibility = useCallback((calendarUrl: string) => {
setVisibleCalendarUrls((prev) => { setVisibleCalendarUrls((prev) => {
const newSet = new Set(prev); const newVisible = new Set(prev);
if (newSet.has(calendarUrl)) { if (newVisible.has(calendarUrl)) {
newSet.delete(calendarUrl); newVisible.delete(calendarUrl);
} else { } else {
newSet.add(calendarUrl); newVisible.add(calendarUrl);
} }
return newSet; // Persist: store the hidden set (all known URLs minus visible)
const allUrls = davCalendars.map((cal) => cal.url);
const newHidden = new Set(allUrls.filter((url) => !newVisible.has(url)));
saveHiddenUrls(newHidden);
return newVisible;
}); });
}, []); }, [davCalendars]);
const createCalendar = useCallback( const createCalendar = useCallback(
async ( async (
params: CalDavCalendarCreate, params: CalDavCalendarCreate,
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; error?: string }> => {
try { try {
// Use Django API to create calendar (creates both CalDAV and Django records) // Use Django API to create calendar (CalDAV only)
await createCalendarApi({ await createCalendarApi({
name: params.displayName, name: params.displayName,
color: params.color, color: params.color,
@@ -256,8 +288,13 @@ export const CalendarContextProvider = ({
const calendarsResult = await caldavService.fetchCalendars(); const calendarsResult = await caldavService.fetchCalendars();
if (isMounted && calendarsResult.success && calendarsResult.data) { if (isMounted && calendarsResult.success && calendarsResult.data) {
setDavCalendars(calendarsResult.data); setDavCalendars(calendarsResult.data);
const hidden = loadHiddenUrls();
setVisibleCalendarUrls( setVisibleCalendarUrls(
new Set(calendarsResult.data.map((cal) => cal.url)), new Set(
calendarsResult.data
.map((cal) => cal.url)
.filter((url) => !hidden.has(url)),
),
); );
} }
setIsLoading(false); setIsLoading(false);

View File

@@ -5,11 +5,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Calendar,
createCalendarApi,
createSubscriptionToken, createSubscriptionToken,
deleteSubscriptionToken, deleteSubscriptionToken,
getCalendars,
getSubscriptionToken, getSubscriptionToken,
GetSubscriptionTokenResult, GetSubscriptionTokenResult,
importEventsApi, importEventsApi,
@@ -17,49 +14,8 @@ import {
SubscriptionToken, SubscriptionToken,
SubscriptionTokenError, SubscriptionTokenError,
SubscriptionTokenParams, SubscriptionTokenParams,
toggleCalendarVisibility,
} from "../api"; } from "../api";
const CALENDARS_KEY = ["calendars"];
/**
* Hook to fetch all calendars.
*/
export const useCalendars = () => {
return useQuery<Calendar[]>({
queryKey: CALENDARS_KEY,
queryFn: getCalendars,
});
};
/**
* Hook to create a new calendar.
*/
export const useCreateCalendar = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createCalendarApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CALENDARS_KEY });
},
});
};
/**
* Hook to toggle calendar visibility.
*/
export const useToggleCalendarVisibility = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: toggleCalendarVisibility,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CALENDARS_KEY });
},
});
};
/** /**
* Result type for useSubscriptionToken hook. * Result type for useSubscriptionToken hook.
*/ */
@@ -139,16 +95,11 @@ export const useDeleteSubscriptionToken = () => {
* Hook to import events from an ICS file. * Hook to import events from an ICS file.
*/ */
export const useImportEvents = () => { export const useImportEvents = () => {
const queryClient = useQueryClient();
return useMutation< return useMutation<
ImportEventsResult, ImportEventsResult,
Error, Error,
{ calendarId: string; file: File } { caldavPath: string; file: File }
>({ >({
mutationFn: ({ calendarId, file }) => importEventsApi(calendarId, file), mutationFn: ({ caldavPath, file }) => importEventsApi(caldavPath, file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CALENDARS_KEY });
},
}); });
}; };