From 0cb9b40530d681274a55515aa55456d0d090fc64 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Sun, 25 Jan 2026 20:32:48 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(back)=20add=20CalendarSubscriptionTok?= =?UTF-8?q?en=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add model for storing calendar subscription tokens with secure token generation and expiration handling for iCal/CalDAV subscription URLs. Co-Authored-By: Claude Opus 4.5 --- src/backend/core/admin.py | 29 +++++++++++++++ src/backend/core/factories.py | 31 ++++++++++++++++ src/backend/core/models.py | 69 +++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 1efc8ae..dba56bb 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -91,3 +91,32 @@ class UserAdmin(auth_admin.UserAdmin): "updated_at", ) 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.""" + + list_display = ( + "calendar_name", + "owner", + "caldav_path", + "token", + "is_active", + "last_accessed_at", + "created_at", + ) + list_filter = ("is_active",) + search_fields = ("calendar_name", "owner__email", "caldav_path", "token") + readonly_fields = ("id", "token", "created_at", "last_accessed_at") + raw_id_fields = ("owner",) diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 8b9a0f7..eef4c00 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -26,3 +26,34 @@ class UserFactory(factory.django.DjangoModelFactory): short_name = factory.Faker("first_name") language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES]) 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.""" + + class Meta: + model = models.CalendarSubscriptionToken + + owner = factory.SubFactory(UserFactory) + caldav_path = factory.LazyAttribute( + lambda obj: f"/calendars/{obj.owner.email}/{fake.uuid4()}/" + ) + calendar_name = factory.Faker("sentence", nb_words=3) + is_active = True diff --git a/src/backend/core/models.py b/src/backend/core/models.py index dd50c1b..9907aed 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -400,3 +400,72 @@ class CalendarShare(models.Model): def __str__(self): return f"{self.calendar.name} shared with {self.shared_with.email}" + + +class CalendarSubscriptionToken(models.Model): + """ + Stores subscription tokens for iCal export. + Each calendar can have one token that allows unauthenticated read-only access + via a public URL for use in external calendar applications. + + This model is standalone and stores the CalDAV path directly, + without requiring a foreign key to the Calendar model. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + # Owner of the calendar (for permission verification) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="subscription_tokens", + ) + + # CalDAV path stored directly (e.g., /calendars/user@example.com/uuid/) + caldav_path = models.CharField( + max_length=512, + help_text=_("CalDAV path of the calendar"), + ) + + # Calendar display name (for UI display) + calendar_name = models.CharField( + max_length=255, + blank=True, + default="", + help_text=_("Display name of the calendar"), + ) + + token = models.UUIDField( + unique=True, + db_index=True, + default=uuid.uuid4, + help_text=_("Secret token used in the subscription URL"), + ) + is_active = models.BooleanField( + default=True, + help_text=_("Whether this subscription token is active"), + ) + last_accessed_at = models.DateTimeField( + null=True, + blank=True, + help_text=_("Last time this subscription URL was accessed"), + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = _("calendar subscription token") + verbose_name_plural = _("calendar subscription tokens") + constraints = [ + models.UniqueConstraint( + fields=["owner", "caldav_path"], + name="unique_token_per_owner_calendar", + ) + ] + indexes = [ + # Composite index for the public iCal endpoint query: + # CalendarSubscriptionToken.objects.filter(token=..., is_active=True) + models.Index(fields=["token", "is_active"], name="token_active_idx"), + ] + + def __str__(self): + return f"Subscription token for {self.calendar_name or self.caldav_path}"