From 3051100f8af4c7221b68c6ba4591cde7f75deb86 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Mon, 9 Feb 2026 20:48:11 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(data)=20remove=20Calendar=20and=20?= =?UTF-8?q?CalendarShare=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only source of truth for those is now in the caldav server --- src/backend/core/admin.py | 10 - src/backend/core/api/serializers.py | 47 +--- src/backend/core/api/viewsets.py | 249 ++++++----------- src/backend/core/factories.py | 17 -- ..._remove_calendarshare_calendar_and_more.py | 19 ++ src/backend/core/models.py | 102 +------ src/backend/core/services/caldav_service.py | 74 ++--- src/backend/core/services/import_service.py | 16 +- src/backend/core/signals.py | 4 - .../tests/authentication/test_backends.py | 2 +- .../core/tests/test_caldav_scheduling.py | 4 +- src/backend/core/tests/test_caldav_service.py | 14 +- src/backend/core/tests/test_import_events.py | 263 ++++++++++-------- .../calendars/src/features/calendar/api.ts | 77 +---- .../components/calendar-list/CalendarList.tsx | 169 ++--------- .../calendar-list/CalendarListItem.tsx | 38 +-- .../calendar-list/ImportEventsModal.tsx | 6 +- .../hooks/useCalendarListState.ts | 8 - .../components/calendar-list/index.ts | 2 +- .../components/calendar-list/types.ts | 17 -- .../components/calendar-list/utils.ts | 38 +++ .../components/left-panel/LeftPanel.tsx | 5 +- .../calendar/contexts/CalendarContext.tsx | 57 +++- .../features/calendar/hooks/useCalendars.ts | 53 +--- 24 files changed, 408 insertions(+), 883 deletions(-) create mode 100644 src/backend/core/migrations/0004_remove_calendarshare_calendar_and_more.py create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/calendar-list/utils.ts diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index dba56bb..8fdb2dd 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -93,16 +93,6 @@ class UserAdmin(auth_admin.UserAdmin): 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) class CalendarSubscriptionTokenAdmin(admin.ModelAdmin): """Admin class for CalendarSubscriptionToken model.""" diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 6891131..05cdc1e 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -114,49 +114,12 @@ class UserMeSerializer(UserSerializer): read_only_fields = UserSerializer.Meta.read_only_fields -# CalDAV serializers -class CalendarSerializer(serializers.ModelSerializer): - """Serializer for Calendar model.""" +class CalendarCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for creating a Calendar (CalDAV only, no Django model).""" - class Meta: - model = models.Calendar - fields = [ - "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"] + name = serializers.CharField(max_length=255) + color = serializers.CharField(max_length=7, required=False, default="#3174ad") + description = serializers.CharField(required=False, default="") class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 38cbd47..fde1afb 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -8,12 +8,11 @@ from urllib.parse import unquote from django.conf import settings from django.core.cache import cache -from django.db import models as db from django.utils.text import slugify import rest_framework as drf -from rest_framework import mixins, status, viewsets from rest_framework import response as drf_response +from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.parsers import MultiPartParser from rest_framework.permissions import AllowAny, IsAuthenticated @@ -262,149 +261,105 @@ class ConfigView(drf.views.APIView): return theme_customization -# CalDAV ViewSets -class CalendarViewSet( - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.CreateModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - viewsets.GenericViewSet, -): - """ - ViewSet for managing user calendars. +# Regex for CalDAV path validation (shared with SubscriptionTokenViewSet) +# Pattern: /calendars/// +CALDAV_PATH_PATTERN = re.compile( + r"^/calendars/[^/]+/[a-zA-Z0-9-]+/$", +) - list: Get all calendars accessible by the user (owned + shared) - retrieve: Get a specific calendar - create: Create a new calendar - update: Update calendar properties - destroy: Delete a calendar + +def _verify_caldav_access(user, caldav_path): + """Verify that the user has access to the CalDAV 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] - serializer_class = serializers.CalendarSerializer + serializer_class = serializers.CalendarCreateSerializer - def get_queryset(self): - """Return calendars owned by or shared with the current user.""" - 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 create(self, request): + """Create a new calendar via CalDAV. - def get_serializer_class(self): - if self.action == "create": - return serializers.CalendarCreateSerializer - return serializers.CalendarSerializer + POST /api/v1.0/calendars/ + Body: { name, color?, description? } + Returns: { caldav_path } + """ + 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() - calendar = service.create_calendar( - user=self.request.user, + caldav_path = service.create_calendar( + user=request.user, name=serializer.validated_data["name"], 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( - serializers.CalendarShareSerializer(share).data, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + {"caldav_path": caldav_path}, + status=status.HTTP_201_CREATED, ) @action( - detail=True, + detail=False, methods=["post"], parser_classes=[MultiPartParser], - url_path="import_events", + 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() + """Import events from an ICS file into a calendar. - # Only the owner can import events - if calendar.owner != request.user: + POST /api/v1.0/calendars/import-events/ + Body (multipart): file=, caldav_path=/calendars/user@.../uuid/ + """ + caldav_path = request.data.get("caldav_path", "") + if not caldav_path: 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, ) @@ -426,7 +381,7 @@ class CalendarViewSet( ics_data = uploaded_file.read() service = ICSImportService() - result = service.import_events(request.user, calendar, ics_data) + result = service.import_events(request.user, caldav_path, ics_data) response_data = { "total_events": result.total_events, @@ -457,48 +412,6 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet): permission_classes = [IsAuthenticated] serializer_class = serializers.CalendarSubscriptionTokenSerializer - # Regex for CalDAV path validation - # Pattern: /calendars/// - # 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/// - """ - # 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): """ Create or get existing subscription token. @@ -516,7 +429,7 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet): calendar_name = create_serializer.validated_data.get("calendar_name", "") # 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( {"error": "You don't have access to this calendar"}, status=status.HTTP_403_FORBIDDEN, @@ -555,10 +468,10 @@ class SubscriptionTokenViewSet(viewsets.GenericViewSet): 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 - if not self._verify_caldav_access(request.user, caldav_path): + 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, diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index eef4c00..4148700 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -28,23 +28,6 @@ class UserFactory(factory.django.DjangoModelFactory): 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): """A factory to create calendar subscription tokens for testing purposes.""" diff --git a/src/backend/core/migrations/0004_remove_calendarshare_calendar_and_more.py b/src/backend/core/migrations/0004_remove_calendarshare_calendar_and_more.py new file mode 100644 index 0000000..c4ecf00 --- /dev/null +++ b/src/backend/core/migrations/0004_remove_calendarshare_calendar_and_more.py @@ -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', + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 9907aed..a23bc21 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1,27 +1,16 @@ """ Declare and configure the models for the calendars core application """ -# pylint: disable=too-many-lines import uuid -from datetime import timedelta from logging import getLogger from django.conf import settings from django.contrib.auth import models as auth_models 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.cache import cache -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.db import models from django.utils.functional import cached_property -from django.utils.translation import get_language, override from django.utils.translation import gettext_lazy as _ from timezone_field import TimeZoneField @@ -225,14 +214,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): def __str__(self): 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): """Email this user.""" 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): """ Stores subscription tokens for iCal export. diff --git a/src/backend/core/services/caldav_service.py b/src/backend/core/services/caldav_service.py index 5627cb8..abf75ca 100644 --- a/src/backend/core/services/caldav_service.py +++ b/src/backend/core/services/caldav_service.py @@ -10,7 +10,6 @@ from django.utils import timezone from caldav import DAVClient from caldav.lib.error import NotFoundError -from core.models import Calendar logger = logging.getLogger(__name__) @@ -385,72 +384,33 @@ class CalendarService: def __init__(self): self.caldav = CalDAVClient() - def create_default_calendar(self, user) -> Calendar: - """ - Create a default calendar for a user. - """ + def create_default_calendar(self, user) -> str: + """Create a default calendar for a user. Returns the caldav_path.""" calendar_id = str(uuid4()) calendar_name = "Mon calendrier" + return self.caldav.create_calendar(user, calendar_name, calendar_id) - # Create calendar in CalDAV server - caldav_path = self.caldav.create_calendar(user, calendar_name, calendar_id) - - # Create local Calendar record - 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. - """ + def create_calendar( # pylint: disable=unused-argument + self, user, name: str, color: str = "#3174ad" + ) -> str: + """Create a new calendar for a user. Returns the caldav_path.""" calendar_id = str(uuid4()) + return self.caldav.create_calendar(user, name, calendar_id) - # Create calendar in CalDAV server - caldav_path = self.caldav.create_calendar(user, name, calendar_id) + def get_events(self, user, caldav_path: str, start=None, end=None) -> list: + """Get events from a calendar. Returns parsed event data.""" + return self.caldav.get_events(user, caldav_path, start, end) - # Create local Calendar record - 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: + def create_event(self, user, caldav_path: str, event_data: dict) -> str: """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( - self, user, calendar: Calendar, event_uid: str, event_data: dict + self, user, caldav_path: str, event_uid: str, event_data: dict ) -> None: """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.""" - self.caldav.delete_event(user, calendar.caldav_path, event_uid) + self.caldav.delete_event(user, caldav_path, event_uid) diff --git a/src/backend/core/services/import_service.py b/src/backend/core/services/import_service.py index 9e6bc1b..7c6600a 100644 --- a/src/backend/core/services/import_service.py +++ b/src/backend/core/services/import_service.py @@ -38,18 +38,26 @@ class ICSImportService: def __init__(self): 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. Sends the raw ICS bytes to SabreDAV's ?import endpoint which handles all ICS parsing, splitting by UID, VALARM repair, and 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() - # caldav_path already includes the base URI prefix - # e.g. /api/v1.0/caldav/calendars/user@example.com/uuid/ - url = f"{self.base_url}{calendar.caldav_path}?import" + # Ensure caldav_path includes the base URI prefix that SabreDAV expects + base_uri = "/api/v1.0/caldav/" + 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 if not outbound_api_key: diff --git a/src/backend/core/signals.py b/src/backend/core/signals.py index e65efbc..02fe84a 100644 --- a/src/backend/core/signals.py +++ b/src/backend/core/signals.py @@ -23,10 +23,6 @@ def provision_default_calendar(sender, instance, created, **kwargs): # pylint: if not created: 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 if not settings.CALDAV_URL: return diff --git a/src/backend/core/tests/authentication/test_backends.py b/src/backend/core/tests/authentication/test_backends.py index f1131fd..d5f5da0 100644 --- a/src/backend/core/tests/authentication/test_backends.py +++ b/src/backend/core/tests/authentication/test_backends.py @@ -500,7 +500,7 @@ def test_authentication_session_tokens( status=200, ) - with django_assert_num_queries(7): + with django_assert_num_queries(5): user = klass.authenticate( request, code="test-code", diff --git a/src/backend/core/tests/test_caldav_scheduling.py b/src/backend/core/tests/test_caldav_scheduling.py index cf0e34c..2d0708c 100644 --- a/src/backend/core/tests/test_caldav_scheduling.py +++ b/src/backend/core/tests/test_caldav_scheduling.py @@ -95,7 +95,7 @@ class TestCalDAVScheduling: # Create calendar for organizer service = CalendarService() - calendar = service.create_calendar( + caldav_path = service.create_calendar( organizer, name="Test Calendar", color="#ff0000" ) @@ -126,7 +126,7 @@ class TestCalDAVScheduling: try: # Create an event with an attendee 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 # The CalDAV server will use this URL for the callback diff --git a/src/backend/core/tests/test_caldav_service.py b/src/backend/core/tests/test_caldav_service.py index 008d182..fe140fb 100644 --- a/src/backend/core/tests/test_caldav_service.py +++ b/src/backend/core/tests/test_caldav_service.py @@ -59,12 +59,10 @@ class TestCalDAVClient: user = factories.UserFactory(email="test@example.com") service = CalendarService() - # Create a calendar - calendar = service.create_calendar(user, name="My Calendar", color="#ff0000") + # Create a calendar — returns caldav_path string + caldav_path = service.create_calendar(user, name="My Calendar", color="#ff0000") - # Verify calendar was created - assert calendar is not None - assert calendar.owner == user - assert calendar.name == "My Calendar" - assert calendar.color == "#ff0000" - assert calendar.caldav_path is not None + # Verify caldav_path was returned + assert caldav_path is not None + assert isinstance(caldav_path, str) + assert "calendars/" in caldav_path diff --git a/src/backend/core/tests/test_import_events.py b/src/backend/core/tests/test_import_events.py index bda6965..07b2156 100644 --- a/src/backend/core/tests/test_import_events.py +++ b/src/backend/core/tests/test_import_events.py @@ -1,6 +1,7 @@ """Tests for the ICS import events feature.""" # pylint: disable=too-many-lines import json +import uuid from datetime import datetime from datetime import timezone as dt_tz from unittest.mock import MagicMock, patch @@ -244,6 +245,11 @@ END:VEVENT 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 status_code=200, total_events=0, @@ -278,10 +284,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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.imported_count == 1 @@ -301,10 +307,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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.imported_count == 3 @@ -321,10 +327,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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.imported_count == 0 @@ -340,10 +346,10 @@ class TestICSImportService: mock_post.return_value.text = '{"error": "Failed to parse ICS file"}' user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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 len(result.errors) >= 1 @@ -356,10 +362,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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.imported_count == 1 @@ -386,10 +392,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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.imported_count == 2 @@ -406,10 +412,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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.imported_count == 1 @@ -422,10 +428,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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.imported_count == 1 @@ -438,10 +444,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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 assert result.total_events == 1 @@ -464,10 +470,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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.imported_count == 0 @@ -476,20 +482,20 @@ class TestICSImportService: @patch("core.services.import_service.requests.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( total_events=1, imported_count=1 ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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 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 @patch("core.services.import_service.requests.post") @@ -500,10 +506,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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 headers = call_kwargs["headers"] @@ -524,10 +530,10 @@ class TestICSImportService: ) user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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.imported_count == 1 @@ -541,10 +547,10 @@ class TestICSImportService: mock_post.side_effect = req.ConnectionError("Connection refused") user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + caldav_path = _make_caldav_path(user) 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 len(result.errors) >= 1 @@ -553,23 +559,19 @@ class TestICSImportService: class TestImportEventsAPI: """API endpoint tests for the import_events action.""" - def _get_url(self, calendar_id): - return f"/api/v1.0/calendars/{calendar_id}/import_events/" + IMPORT_URL = "/api/v1.0/calendars/import-events/" def test_import_events_requires_authentication(self): """Unauthenticated requests should be rejected.""" - calendar = factories.CalendarFactory() client = APIClient() - - response = client.post(self._get_url(calendar.id)) - + response = client.post(self.IMPORT_URL) assert response.status_code == 401 - def test_import_events_forbidden_for_non_owner(self): - """Non-owners should not be able to access the calendar.""" - owner = factories.UserFactory() - other_user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=owner) + def test_import_events_forbidden_for_wrong_user(self): + """Users cannot import to a calendar they don't own.""" + owner = factories.UserFactory(email="owner@example.com") + other_user = factories.UserFactory(email="other@example.com") + caldav_path = f"/calendars/{owner.email}/some-uuid/" client = APIClient() client.force_login(other_user) @@ -578,29 +580,45 @@ class TestImportEventsAPI: "events.ics", ICS_SINGLE_EVENT, content_type="text/calendar" ) 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) - assert response.status_code == 404 + def test_import_events_missing_caldav_path(self): + """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): """Request without a file should return 400.""" - user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + user = factories.UserFactory(email="nofile@example.com") + caldav_path = f"/calendars/{user.email}/some-uuid/" client = APIClient() 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 "No file provided" in response.json()["error"] def test_import_events_file_too_large(self): """Files exceeding MAX_FILE_SIZE should be rejected.""" - user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + user = factories.UserFactory(email="largefile@example.com") + caldav_path = f"/calendars/{user.email}/some-uuid/" client = APIClient() client.force_login(user) @@ -611,9 +629,10 @@ class TestImportEventsAPI: content_type="text/calendar", ) 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 "too large" in response.json()["error"] @@ -628,8 +647,8 @@ class TestImportEventsAPI: errors=[], ) - user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + user = factories.UserFactory(email="success@example.com") + caldav_path = f"/calendars/{user.email}/some-uuid/" client = APIClient() client.force_login(user) @@ -638,7 +657,9 @@ class TestImportEventsAPI: "events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar" ) 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 @@ -659,8 +680,8 @@ class TestImportEventsAPI: errors=["Planning session"], ) - user = factories.UserFactory() - calendar = factories.CalendarFactory(owner=user) + user = factories.UserFactory(email="partial@example.com") + caldav_path = f"/calendars/{user.email}/some-uuid/" client = APIClient() client.force_login(user) @@ -669,7 +690,9 @@ class TestImportEventsAPI: "events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar" ) 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 @@ -688,17 +711,17 @@ class TestImportEventsE2E: """End-to-end tests that import ICS events through the real SabreDAV server.""" 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() return service.create_calendar(user, name="Import Test", color="#3174ad") def test_import_single_event_e2e(self): """Import a single event and verify it exists in SabreDAV.""" user = factories.UserFactory(email="import-single@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) 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.imported_count == 1 @@ -709,7 +732,7 @@ class TestImportEventsE2E: caldav = CalDAVClient() events = caldav.get_events( user, - calendar.caldav_path, + caldav_path, start=datetime(2026, 2, 10, 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): """Import multiple events and verify they all exist in SabreDAV.""" user = factories.UserFactory(email="import-multi@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) 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.imported_count == 3 @@ -733,7 +756,7 @@ class TestImportEventsE2E: caldav = CalDAVClient() events = caldav.get_events( user, - calendar.caldav_path, + caldav_path, start=datetime(2026, 2, 10, 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): """Import an all-day event and verify it exists in SabreDAV.""" user = factories.UserFactory(email="import-allday@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) 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.imported_count == 1 @@ -757,7 +780,7 @@ class TestImportEventsE2E: caldav = CalDAVClient() events = caldav.get_events( user, - calendar.caldav_path, + caldav_path, start=datetime(2026, 2, 14, 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): """Import an event with timezone info and verify it in SabreDAV.""" user = factories.UserFactory(email="import-tz@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) 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.imported_count == 1 @@ -780,7 +803,7 @@ class TestImportEventsE2E: caldav = CalDAVClient() events = caldav.get_events( user, - calendar.caldav_path, + caldav_path, start=datetime(2026, 2, 10, 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): """Import events via the API endpoint hitting real SabreDAV.""" user = factories.UserFactory(email="import-api@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) client = APIClient() client.force_login(user) @@ -799,8 +822,8 @@ class TestImportEventsE2E: "events.ics", ICS_MULTIPLE_EVENTS, content_type="text/calendar" ) response = client.post( - f"/api/v1.0/calendars/{calendar.id}/import_events/", - {"file": ics_file}, + "/api/v1.0/calendars/import-events/", + {"file": ics_file, "caldav_path": caldav_path}, format="multipart", ) @@ -814,7 +837,7 @@ class TestImportEventsE2E: caldav = CalDAVClient() events = caldav.get_events( user, - calendar.caldav_path, + caldav_path, start=datetime(2026, 2, 10, 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. """ user = factories.UserFactory(email="import-attendee@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) # Import event with attendees 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.imported_count == 1 @@ -842,7 +865,7 @@ class TestImportEventsE2E: caldav = CalDAVClient() caldav.update_event( user, - calendar.caldav_path, + caldav_path, "attendee-event-1", {"title": "Updated review meeting"}, ) @@ -850,7 +873,7 @@ class TestImportEventsE2E: # Verify update was applied events = caldav.get_events( user, - calendar.caldav_path, + caldav_path, start=datetime(2026, 2, 10, 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. """ user = factories.UserFactory(email="import-escapes@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) import_service = ICSImportService() 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 @@ -880,7 +903,7 @@ class TestImportEventsE2E: caldav = CalDAVClient() events = caldav.get_events( user, - calendar.caldav_path, + caldav_path, start=datetime(2026, 2, 10, 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): """Importing the same ICS file twice should not create duplicates.""" user = factories.UserFactory(email="import-dedup@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) import_service = ICSImportService() # 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 not result1.errors # 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.imported_count == 0 assert result2.skipped_count == 0 @@ -909,7 +932,7 @@ class TestImportEventsE2E: caldav = CalDAVClient() events = caldav.get_events( user, - calendar.caldav_path, + caldav_path, start=datetime(2026, 2, 10, 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): """A recurring event whose EXDATE excludes all instances is skipped, not an error.""" user = factories.UserFactory(email="import-dead-recur@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) 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.imported_count == 0 assert result.skipped_count == 1 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.""" caldav_client = CalDAVClient() 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) event = cal.event_by_uid(uid) return event.data @@ -940,11 +963,11 @@ class TestImportEventsE2E: def test_import_strips_binary_attachments_e2e(self): """Binary attachments should be stripped during import.""" user = factories.UserFactory(email="import-strip-attach@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) import_service = ICSImportService() result = import_service.import_events( - user, calendar, ICS_WITH_BINARY_ATTACHMENT + user, caldav_path, ICS_WITH_BINARY_ATTACHMENT ) assert result.total_events == 1 @@ -952,7 +975,7 @@ class TestImportEventsE2E: assert not result.errors # 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 "iVBORw0KGgo" not in raw assert "ATTACH" not in raw @@ -960,28 +983,30 @@ class TestImportEventsE2E: def test_import_keeps_url_attachments_e2e(self): """URL-based attachments should NOT be stripped during import.""" user = factories.UserFactory(email="import-keep-url-attach@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) 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.imported_count == 1 assert not result.errors # 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 "ATTACH" in raw def test_import_truncates_large_description_e2e(self): """Descriptions exceeding IMPORT_MAX_DESCRIPTION_BYTES should be truncated.""" user = factories.UserFactory(email="import-trunc-desc@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) import_service = ICSImportService() result = import_service.import_events( - user, calendar, ICS_WITH_LARGE_DESCRIPTION + user, caldav_path, ICS_WITH_LARGE_DESCRIPTION ) assert result.total_events == 1 @@ -989,7 +1014,7 @@ class TestImportEventsE2E: assert not result.errors # 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 # Raw ICS should be much smaller than the 200KB original assert len(raw) < 150000 @@ -1005,15 +1030,15 @@ class TestCalendarSanitizerE2E: """E2E tests for CalendarSanitizerPlugin on normal CalDAV PUT operations.""" 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() 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.""" caldav_client = CalDAVClient() 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) event = cal.event_by_uid(uid) return event.data @@ -1021,14 +1046,12 @@ class TestCalendarSanitizerE2E: def test_caldav_put_strips_binary_attachment_e2e(self): """A normal CalDAV PUT with binary attachment should be sanitized.""" user = factories.UserFactory(email="sanitizer-put-attach@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) caldav = CalDAVClient() - caldav.create_event_raw( - user, calendar.caldav_path, ICS_WITH_BINARY_ATTACHMENT.decode() - ) + caldav.create_event_raw(user, 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 "iVBORw0KGgo" not in raw assert "ATTACH" not in raw @@ -1036,28 +1059,24 @@ class TestCalendarSanitizerE2E: def test_caldav_put_keeps_url_attachment_e2e(self): """A normal CalDAV PUT with URL attachment should preserve it.""" user = factories.UserFactory(email="sanitizer-put-url@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) caldav = CalDAVClient() - caldav.create_event_raw( - user, calendar.caldav_path, ICS_WITH_URL_ATTACHMENT.decode() - ) + caldav.create_event_raw(user, 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 "ATTACH" in raw def test_caldav_put_truncates_large_description_e2e(self): """A normal CalDAV PUT with oversized description should be truncated.""" user = factories.UserFactory(email="sanitizer-put-desc@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) caldav = CalDAVClient() - caldav.create_event_raw( - user, calendar.caldav_path, ICS_WITH_LARGE_DESCRIPTION.decode() - ) + caldav.create_event_raw(user, 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 len(raw) < 150000 assert "..." in raw @@ -1065,23 +1084,21 @@ class TestCalendarSanitizerE2E: def test_caldav_put_rejects_oversized_event_e2e(self): """A CalDAV PUT exceeding max-resource-size should be rejected (HTTP 507).""" user = factories.UserFactory(email="sanitizer-put-oversize@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) caldav = CalDAVClient() with pytest.raises(Exception) as exc_info: - caldav.create_event_raw( - user, calendar.caldav_path, ICS_OVERSIZED_EVENT.decode() - ) + caldav.create_event_raw(user, caldav_path, ICS_OVERSIZED_EVENT.decode()) # SabreDAV returns 507 Insufficient Storage assert "507" in str(exc_info.value) or "Insufficient" in str(exc_info.value) def test_import_rejects_oversized_event_e2e(self): """Import of an event exceeding max-resource-size should skip it.""" user = factories.UserFactory(email="sanitizer-import-oversize@example.com") - calendar = self._create_calendar(user) + caldav_path = self._create_calendar(user) 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.imported_count == 0 diff --git a/src/frontend/apps/calendars/src/features/calendar/api.ts b/src/frontend/apps/calendars/src/features/calendar/api.ts index 07826cb..02834c1 100644 --- a/src/frontend/apps/calendars/src/features/calendar/api.ts +++ b/src/frontend/apps/calendars/src/features/calendar/api.ts @@ -4,46 +4,14 @@ 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. - */ -interface PaginatedResponse { - count: number; - next: string | null; - previous: string | null; - results: T[]; -} - -/** - * Fetch all calendars accessible by the current user. - */ -export const getCalendars = async (): Promise => { - const response = await fetchAPI("calendars/"); - const data: PaginatedResponse = await response.json(); - return data.results; -}; - -/** - * Create a new calendar via Django API. - * This creates both the CalDAV calendar and the Django record. + * Create a new calendar via Django API (CalDAV only). */ export const createCalendarApi = async (data: { name: string; color?: string; description?: string; -}): Promise => { +}): Promise<{ caldav_path: string }> => { const response = await fetchAPI("calendars/", { method: "POST", body: JSON.stringify(data), @@ -51,41 +19,6 @@ export const createCalendarApi = async (data: { return response.json(); }; -/** - * Update an existing calendar via Django API. - */ -export const updateCalendarApi = async ( - calendarId: string, - data: { name?: string; color?: string; description?: string } -): Promise => { - 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 => { - 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. */ @@ -220,14 +153,15 @@ export interface ImportEventsResult { * Import events from an ICS file into a calendar. */ export const importEventsApi = async ( - calendarId: string, + caldavPath: string, file: File, ): Promise => { const formData = new FormData(); formData.append("file", file); + formData.append("caldav_path", caldavPath); const response = await fetchAPIFormData( - `calendars/${calendarId}/import_events/`, + "calendars/import-events/", { method: "POST", body: formData, @@ -235,4 +169,3 @@ export const importEventsApi = async ( ); return response.json(); }; - diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx index 851afaa..8d6639f 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/CalendarList.tsx @@ -2,26 +2,22 @@ * 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 { useQueryClient } from "@tanstack/react-query"; -import type { Calendar } from "../../types"; import { useCalendarContext } from "../../contexts"; import { CalendarModal } from "./CalendarModal"; import { DeleteConfirmModal } from "./DeleteConfirmModal"; import { ImportEventsModal } from "./ImportEventsModal"; import { SubscriptionUrlModal } from "./SubscriptionUrlModal"; -import { CalendarListItem, SharedCalendarListItem } from "./CalendarListItem"; +import { CalendarListItem } from "./CalendarListItem"; import { useCalendarListState } from "./hooks/useCalendarListState"; -import type { CalendarListProps } from "./types"; import type { CalDavCalendar } from "../../services/dav/types/caldav-service"; -import { Calendar as DjangoCalendar, getCalendars } from "../../api"; +import { extractCaldavPath } from "./utils"; -export const CalendarList = ({ calendars }: CalendarListProps) => { +export const CalendarList = () => { const { t } = useTranslation(); - const queryClient = useQueryClient(); const { davCalendars, visibleCalendarUrls, @@ -37,7 +33,6 @@ export const CalendarList = ({ calendars }: CalendarListProps) => { modalState, deleteState, isMyCalendarsExpanded, - isSharedCalendarsExpanded, openMenuUrl, handleOpenCreateModal, handleOpenEditModal, @@ -50,7 +45,6 @@ export const CalendarList = ({ calendars }: CalendarListProps) => { handleMenuToggle, handleCloseMenu, handleToggleMyCalendars, - handleToggleSharedCalendars, } = useCalendarListState({ createCalendar, updateCalendar, @@ -66,107 +60,42 @@ export const CalendarList = ({ calendars }: CalendarListProps) => { }>({ isOpen: false, calendarName: "", caldavPath: null }); const handleOpenSubscriptionModal = (davCalendar: CalDavCalendar) => { - try { - // Extract the CalDAV path from the calendar URL - // URL format: http://localhost:8921/api/v1.0/caldav/calendars/user@example.com/uuid/ - 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 }); + const caldavPath = extractCaldavPath(davCalendar.url); + if (!caldavPath) { + return; } + setSubscriptionModal({ + isOpen: true, + calendarName: davCalendar.displayName || "", + caldavPath, + }); }; const handleCloseSubscriptionModal = () => { setSubscriptionModal({ isOpen: false, calendarName: "", caldavPath: null }); }; - // Ensure calendars is an array - const calendarsArray = Array.isArray(calendars) ? calendars : []; - // Import modal state const [importModal, setImportModal] = useState<{ isOpen: boolean; - calendarId: string | null; + caldavPath: string | null; calendarName: string; - }>({ isOpen: false, calendarId: null, calendarName: "" }); + }>({ isOpen: false, caldavPath: null, calendarName: "" }); - const handleOpenImportModal = async (davCalendar: CalDavCalendar) => { - try { - // Extract the CalDAV path from the calendar URL - const url = new URL(davCalendar.url); - const pathParts = url.pathname.split("/").filter(Boolean); - const calendarsIndex = pathParts.findIndex((part) => part === "calendars"); - - if (calendarsIndex === -1 || pathParts.slice(calendarsIndex).length < 3) { - console.error("Invalid calendar URL format:", davCalendar.url); - return; - } - - const caldavPath = "/" + pathParts.slice(calendarsIndex).join("/") + "/"; - - // Find the matching Django Calendar by caldav_path - const caldavApiRoot = "/api/v1.0/caldav"; - const normalize = (p: string) => - decodeURIComponent(p).replace(caldavApiRoot, "").replace(/\/+$/, ""); - - const findCalendar = (cals: DjangoCalendar[]) => - cals.find((cal) => normalize(cal.caldav_path) === normalize(caldavPath)); - - // Fetch fresh Django calendars to ensure newly created calendars are included. - // Uses React Query cache, forcing a refetch if stale. - const freshCalendars = await queryClient.fetchQuery({ - queryKey: ["calendars"], - queryFn: getCalendars, - }); - const djangoCalendar = findCalendar(freshCalendars); - - if (!djangoCalendar) { - console.error("No matching Django calendar found for path:", caldavPath); - return; - } - - setImportModal({ - isOpen: true, - calendarId: djangoCalendar.id, - calendarName: davCalendar.displayName || "", - }); - } catch (error) { - console.error("Failed to parse calendar URL:", error); + const handleOpenImportModal = (davCalendar: CalDavCalendar) => { + const caldavPath = extractCaldavPath(davCalendar.url); + if (!caldavPath) { + return; } + setImportModal({ + isOpen: true, + caldavPath, + calendarName: davCalendar.displayName || "", + }); }; const handleCloseImportModal = () => { - setImportModal({ isOpen: false, calendarId: null, calendarName: "" }); + setImportModal({ isOpen: false, caldavPath: null, calendarName: "" }); }; const handleImportSuccess = useCallback(() => { @@ -175,15 +104,6 @@ export const CalendarList = ({ calendars }: CalendarListProps) => { } }, [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 ( <>
@@ -235,43 +155,6 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
)} - - {sharedCalendars.length > 0 && ( -
-
- -
- {isSharedCalendarsExpanded && ( -
- {sharedCalendars.map((calendar: Calendar) => ( - - ))} -
- )} -
- )} { /> )} - {importModal.isOpen && importModal.calendarId && ( + {importModal.isOpen && importModal.caldavPath && ( { - const { t } = useTranslation(); - - return ( -
-
- onToggleVisibility(String(calendar.id))} - label="" - aria-label={`${t("calendar.list.showCalendar")} ${calendar.name}`} - /> - -
- - {calendar.name} - -
- ); -}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/ImportEventsModal.tsx b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/ImportEventsModal.tsx index a713d0f..937184d 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/ImportEventsModal.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/ImportEventsModal.tsx @@ -13,7 +13,7 @@ import type { ImportEventsResult } from "../../api"; interface ImportEventsModalProps { isOpen: boolean; - calendarId: string; + caldavPath: string; calendarName: string; onClose: () => void; onImportSuccess?: () => void; @@ -21,7 +21,7 @@ interface ImportEventsModalProps { export const ImportEventsModal = ({ isOpen, - calendarId, + caldavPath, calendarName, onClose, onImportSuccess, @@ -42,7 +42,7 @@ export const ImportEventsModal = ({ if (!selectedFile) return; const importResult = await importMutation.mutateAsync({ - calendarId, + caldavPath, file: selectedFile, }); setResult(importResult); diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/hooks/useCalendarListState.ts b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/hooks/useCalendarListState.ts index b239276..a4e840f 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/hooks/useCalendarListState.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/hooks/useCalendarListState.ts @@ -47,8 +47,6 @@ export const useCalendarListState = ({ }); const [isMyCalendarsExpanded, setIsMyCalendarsExpanded] = useState(true); - const [isSharedCalendarsExpanded, setIsSharedCalendarsExpanded] = - useState(true); const [openMenuUrl, setOpenMenuUrl] = useState(null); // Modal handlers @@ -162,10 +160,6 @@ export const useCalendarListState = ({ setIsMyCalendarsExpanded((prev) => !prev); }, []); - const handleToggleSharedCalendars = useCallback(() => { - setIsSharedCalendarsExpanded((prev) => !prev); - }, []); - return { // Modal state modalState, @@ -173,7 +167,6 @@ export const useCalendarListState = ({ // Expansion state isMyCalendarsExpanded, - isSharedCalendarsExpanded, openMenuUrl, // Modal handlers @@ -194,6 +187,5 @@ export const useCalendarListState = ({ // Expansion handlers handleToggleMyCalendars, - handleToggleSharedCalendars, }; }; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/index.ts b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/index.ts index 71cd0fd..be5d0da 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/index.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/index.ts @@ -7,7 +7,7 @@ export { CalendarModal } from "./CalendarModal"; export { CalendarItemMenu } from "./CalendarItemMenu"; export { DeleteConfirmModal } from "./DeleteConfirmModal"; export { SubscriptionUrlModal } from "./SubscriptionUrlModal"; -export { CalendarListItem, SharedCalendarListItem } from "./CalendarListItem"; +export { CalendarListItem } from "./CalendarListItem"; export { useCalendarListState } from "./hooks/useCalendarListState"; export { DEFAULT_COLORS } from "./constants"; export * from "./types"; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts index 374fa95..03ec398 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/types.ts @@ -3,7 +3,6 @@ */ import type { CalDavCalendar } from "../../services/dav/types/caldav-service"; -import type { Calendar } from "../../types"; /** * Props for the CalendarModal component. @@ -56,22 +55,6 @@ export interface CalendarListItemProps { 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. */ diff --git a/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/utils.ts b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/utils.ts new file mode 100644 index 0000000..bb8bf0f --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/calendar-list/utils.ts @@ -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; + } +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/left-panel/LeftPanel.tsx b/src/frontend/apps/calendars/src/features/calendar/components/left-panel/LeftPanel.tsx index cf07f31..80b2fb1 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/left-panel/LeftPanel.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/left-panel/LeftPanel.tsx @@ -11,7 +11,6 @@ import { CalendarList } from "../calendar-list"; import { MiniCalendar } from "./MiniCalendar"; import { EventModal } from "../scheduler/EventModal"; import { useCalendarContext } from "../../contexts"; -import { useCalendars } from "../../hooks/useCalendars"; const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -44,8 +43,6 @@ export const LeftPanel = () => { calendarRef, } = useCalendarContext(); - const { data: calendars = [] } = useCalendars(); - // Get default calendar URL const defaultCalendarUrl = davCalendars[0]?.url || ""; @@ -140,7 +137,7 @@ export const LeftPanel = () => {
- +
{modal.isOpen && ( diff --git a/src/frontend/apps/calendars/src/features/calendar/contexts/CalendarContext.tsx b/src/frontend/apps/calendars/src/features/calendar/contexts/CalendarContext.tsx index 5349ea6..e889517 100644 --- a/src/frontend/apps/calendars/src/features/calendar/contexts/CalendarContext.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/contexts/CalendarContext.tsx @@ -18,6 +18,31 @@ import type { import type { CalendarApi } from "../components/scheduler/types"; import { createCalendarApi } from "../api"; +const HIDDEN_CALENDARS_KEY = "calendar-hidden-urls"; + +const loadHiddenUrls = (): Set => { + 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) => { + try { + localStorage.setItem( + HIDDEN_CALENDARS_KEY, + JSON.stringify([...hiddenUrls]), + ); + } catch { + // Ignore storage errors + } +}; + export interface CalendarContextType { calendarRef: React.RefObject; caldavService: CalDavService; @@ -88,8 +113,11 @@ export const CalendarContextProvider = ({ const result = await caldavService.fetchCalendars(); if (result.success && result.data) { setDavCalendars(result.data); - // Initialize all calendars as visible - setVisibleCalendarUrls(new Set(result.data.map((cal) => cal.url))); + // Compute visible = all minus hidden (new calendars default to visible) + const hidden = loadHiddenUrls(); + setVisibleCalendarUrls( + new Set(result.data.map((cal) => cal.url).filter((url) => !hidden.has(url))), + ); } else { console.error("Error fetching calendars:", result.error); setDavCalendars([]); @@ -106,22 +134,26 @@ export const CalendarContextProvider = ({ const toggleCalendarVisibility = useCallback((calendarUrl: string) => { setVisibleCalendarUrls((prev) => { - const newSet = new Set(prev); - if (newSet.has(calendarUrl)) { - newSet.delete(calendarUrl); + const newVisible = new Set(prev); + if (newVisible.has(calendarUrl)) { + newVisible.delete(calendarUrl); } 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( async ( params: CalDavCalendarCreate, ): Promise<{ success: boolean; error?: string }> => { try { - // Use Django API to create calendar (creates both CalDAV and Django records) + // Use Django API to create calendar (CalDAV only) await createCalendarApi({ name: params.displayName, color: params.color, @@ -256,8 +288,13 @@ export const CalendarContextProvider = ({ const calendarsResult = await caldavService.fetchCalendars(); if (isMounted && calendarsResult.success && calendarsResult.data) { setDavCalendars(calendarsResult.data); + const hidden = loadHiddenUrls(); setVisibleCalendarUrls( - new Set(calendarsResult.data.map((cal) => cal.url)), + new Set( + calendarsResult.data + .map((cal) => cal.url) + .filter((url) => !hidden.has(url)), + ), ); } setIsLoading(false); diff --git a/src/frontend/apps/calendars/src/features/calendar/hooks/useCalendars.ts b/src/frontend/apps/calendars/src/features/calendar/hooks/useCalendars.ts index 1161e8d..8b1c669 100644 --- a/src/frontend/apps/calendars/src/features/calendar/hooks/useCalendars.ts +++ b/src/frontend/apps/calendars/src/features/calendar/hooks/useCalendars.ts @@ -5,11 +5,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - Calendar, - createCalendarApi, createSubscriptionToken, deleteSubscriptionToken, - getCalendars, getSubscriptionToken, GetSubscriptionTokenResult, importEventsApi, @@ -17,49 +14,8 @@ import { SubscriptionToken, SubscriptionTokenError, SubscriptionTokenParams, - toggleCalendarVisibility, } from "../api"; -const CALENDARS_KEY = ["calendars"]; - -/** - * Hook to fetch all calendars. - */ -export const useCalendars = () => { - return useQuery({ - 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. */ @@ -139,16 +95,11 @@ export const useDeleteSubscriptionToken = () => { * Hook to import events from an ICS file. */ export const useImportEvents = () => { - const queryClient = useQueryClient(); - return useMutation< ImportEventsResult, Error, - { calendarId: string; file: File } + { caldavPath: string; file: File } >({ - mutationFn: ({ calendarId, file }) => importEventsApi(calendarId, file), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: CALENDARS_KEY }); - }, + mutationFn: ({ caldavPath, file }) => importEventsApi(caldavPath, file), }); };