🐛(data) remove Calendar and CalendarShare models
The only source of truth for those is now in the caldav server
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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/<email-or-encoded>/<calendar-id>/
|
||||
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=<ics>, 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/<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):
|
||||
"""
|
||||
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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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.
|
||||
* Create a new calendar via Django API (CalDAV only).
|
||||
*/
|
||||
export const createCalendarApi = async (data: {
|
||||
name: string;
|
||||
color?: string;
|
||||
description?: string;
|
||||
}): Promise<Calendar> => {
|
||||
}): 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<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.
|
||||
*/
|
||||
@@ -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<ImportEventsResult> => {
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="calendar-list">
|
||||
@@ -235,43 +155,6 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
|
||||
</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>
|
||||
|
||||
<CalendarModal
|
||||
@@ -300,10 +183,10 @@ export const CalendarList = ({ calendars }: CalendarListProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{importModal.isOpen && importModal.calendarId && (
|
||||
{importModal.isOpen && importModal.caldavPath && (
|
||||
<ImportEventsModal
|
||||
isOpen={importModal.isOpen}
|
||||
calendarId={importModal.calendarId}
|
||||
caldavPath={importModal.caldavPath}
|
||||
calendarName={importModal.calendarName}
|
||||
onClose={handleCloseImportModal}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
|
||||
@@ -7,10 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@gouvfr-lasuite/cunningham-react";
|
||||
|
||||
import { CalendarItemMenu } from "./CalendarItemMenu";
|
||||
import type {
|
||||
CalendarListItemProps,
|
||||
SharedCalendarListItemProps,
|
||||
} from "./types";
|
||||
import type { CalendarListItemProps } from "./types";
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -47,8 +47,6 @@ export const useCalendarListState = ({
|
||||
});
|
||||
|
||||
const [isMyCalendarsExpanded, setIsMyCalendarsExpanded] = useState(true);
|
||||
const [isSharedCalendarsExpanded, setIsSharedCalendarsExpanded] =
|
||||
useState(true);
|
||||
const [openMenuUrl, setOpenMenuUrl] = useState<string | null>(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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 = () => {
|
||||
|
||||
<div className="calendar-left-panel__divider" />
|
||||
|
||||
<CalendarList calendars={calendars} />
|
||||
<CalendarList />
|
||||
</div>
|
||||
|
||||
{modal.isOpen && (
|
||||
|
||||
@@ -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<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 {
|
||||
calendarRef: React.RefObject<CalendarApi | null>;
|
||||
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);
|
||||
|
||||
@@ -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<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.
|
||||
*/
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user