UMBRA/backend/app/schemas/calendar_event.py
Kyle Pope d89758fedf Add materialized recurring events backend
- Migration 007: parent_event_id (self-ref FK CASCADE), is_recurring, original_start columns on calendar_events
- CalendarEvent model: three new Mapped[] columns for recurrence tracking
- RecurrenceRule Pydantic model: typed schema for every_n_days, weekly, monthly_nth_weekday, monthly_date
- CalendarEventCreate/Update: accept structured RecurrenceRule (router serializes to JSON string for DB)
- CalendarEventUpdate: edit_scope field (this | this_and_future)
- CalendarEventResponse: exposes parent_event_id, is_recurring, original_start
- recurrence.py service: generates unsaved child ORM objects from parent rule up to 365-day horizon
- GET /: excludes parent template rows (children are displayed instead)
- POST /: creates parent + bulk children when recurrence_rule provided
- PUT /: scope=this detaches occurrence; scope=this_and_future deletes future siblings and regenerates
- DELETE /: scope=this deletes one; scope=this_and_future deletes future siblings; no scope deletes all (CASCADE)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:37:21 +08:00

70 lines
2.3 KiB
Python

from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Literal, Optional
class RecurrenceRule(BaseModel):
"""Structured recurrence rule — serialized to/from JSON string in the DB column."""
type: Literal["every_n_days", "weekly", "monthly_nth_weekday", "monthly_date"]
# every_n_days
interval: Optional[int] = None
# weekly / monthly_nth_weekday
weekday: Optional[int] = None # 0=Mon … 6=Sun
# monthly_nth_weekday
week: Optional[int] = None # 1-4
# monthly_date
day: Optional[int] = None # 1-31
class CalendarEventCreate(BaseModel):
title: str
description: Optional[str] = None
start_datetime: datetime
end_datetime: datetime
all_day: bool = False
color: Optional[str] = None
location_id: Optional[int] = None
recurrence_rule: Optional[RecurrenceRule] = None # structured; router serializes to JSON string
is_starred: bool = False
calendar_id: Optional[int] = None # If None, server assigns default calendar
class CalendarEventUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
start_datetime: Optional[datetime] = None
end_datetime: Optional[datetime] = None
all_day: Optional[bool] = None
color: Optional[str] = None
location_id: Optional[int] = None
recurrence_rule: Optional[RecurrenceRule] = None # structured; router serializes to JSON string
is_starred: Optional[bool] = None
calendar_id: Optional[int] = None
# Controls which occurrences an edit applies to; absent = non-recurring or whole-series
edit_scope: Optional[Literal["this", "this_and_future"]] = None
class CalendarEventResponse(BaseModel):
id: int
title: str
description: Optional[str]
start_datetime: datetime
end_datetime: datetime
all_day: bool
color: Optional[str]
location_id: Optional[int]
recurrence_rule: Optional[str] # raw JSON string from DB; frontend parses
is_starred: bool
calendar_id: int
calendar_name: str
calendar_color: str
is_virtual: bool = False
# Recurrence fields
parent_event_id: Optional[int] = None
is_recurring: bool = False
original_start: Optional[datetime] = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)