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>
This commit is contained in:
Kyle 2026-02-22 00:37:21 +08:00
parent 5b056cf674
commit d89758fedf
5 changed files with 442 additions and 34 deletions

View File

@ -0,0 +1,62 @@
"""Add recurrence fields to calendar_events
Revision ID: 007
Revises: 006
Create Date: 2026-02-22 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '007'
down_revision: Union[str, None] = '006'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# parent_event_id: self-referential FK, nullable, ON DELETE CASCADE
op.add_column(
'calendar_events',
sa.Column('parent_event_id', sa.Integer(), nullable=True)
)
op.create_foreign_key(
'fk_calendar_events_parent_event_id',
'calendar_events',
'calendar_events',
['parent_event_id'],
['id'],
ondelete='CASCADE',
)
# is_recurring: tracks whether this row is part of a recurring series
op.add_column(
'calendar_events',
sa.Column(
'is_recurring',
sa.Boolean(),
nullable=False,
server_default='false',
)
)
# original_start: the originally computed occurrence datetime (naive, no TZ)
op.add_column(
'calendar_events',
sa.Column('original_start', sa.DateTime(), nullable=True)
)
def downgrade() -> None:
op.drop_column('calendar_events', 'original_start')
op.drop_column('calendar_events', 'is_recurring')
op.drop_constraint(
'fk_calendar_events_parent_event_id',
'calendar_events',
type_='foreignkey',
)
op.drop_column('calendar_events', 'parent_event_id')

View File

@ -19,6 +19,19 @@ class CalendarEvent(Base):
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
is_starred: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") is_starred: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
calendar_id: Mapped[int] = mapped_column(Integer, ForeignKey("calendars.id"), nullable=False) calendar_id: Mapped[int] = mapped_column(Integer, ForeignKey("calendars.id"), nullable=False)
# Recurrence fields
# parent_event_id: set on child events; NULL on the parent template row
parent_event_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("calendar_events.id", ondelete="CASCADE"),
nullable=True,
)
# is_recurring: True on both the parent template and all generated children
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# original_start: the originally computed occurrence datetime (children only)
original_start: Mapped[Optional[datetime]] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -1,17 +1,24 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, delete
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from typing import Optional, List, Any from typing import Optional, List, Any, Literal
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from app.database import get_db from app.database import get_db
from app.models.calendar_event import CalendarEvent from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar from app.models.calendar import Calendar
from app.models.person import Person from app.models.person import Person
from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse from app.schemas.calendar_event import (
CalendarEventCreate,
CalendarEventUpdate,
CalendarEventResponse,
)
from app.routers.auth import get_current_session from app.routers.auth import get_current_session
from app.models.settings import Settings from app.models.settings import Settings
from app.services.recurrence import generate_occurrences
router = APIRouter() router = APIRouter()
@ -33,6 +40,9 @@ def _event_to_dict(event: CalendarEvent) -> dict:
"calendar_name": event.calendar.name if event.calendar else "", "calendar_name": event.calendar.name if event.calendar else "",
"calendar_color": event.calendar.color if event.calendar else "", "calendar_color": event.calendar.color if event.calendar else "",
"is_virtual": False, "is_virtual": False,
"parent_event_id": event.parent_event_id,
"is_recurring": event.is_recurring,
"original_start": event.original_start,
"created_at": event.created_at, "created_at": event.created_at,
"updated_at": event.updated_at, "updated_at": event.updated_at,
} }
@ -56,7 +66,6 @@ def _birthday_events_for_range(
continue continue
bday: date = person.birthday bday: date = person.birthday
# Generate for each year the birthday falls in the range
for year in range(range_start.year, range_end.year + 1): for year in range(range_start.year, range_end.year + 1):
try: try:
bday_this_year = bday.replace(year=year) bday_this_year = bday.replace(year=year)
@ -86,6 +95,9 @@ def _birthday_events_for_range(
"calendar_name": "Birthdays", "calendar_name": "Birthdays",
"calendar_color": bday_calendar_color, "calendar_color": bday_calendar_color,
"is_virtual": True, "is_virtual": True,
"parent_event_id": None,
"is_recurring": False,
"original_start": None,
"created_at": start_dt, "created_at": start_dt,
"updated_at": start_dt, "updated_at": start_dt,
}) })
@ -107,14 +119,29 @@ async def get_events(
start: Optional[date] = Query(None), start: Optional[date] = Query(None),
end: Optional[date] = Query(None), end: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session),
) -> List[Any]: ) -> List[Any]:
"""Get all calendar events with optional date range filtering, including virtual birthday events.""" """
Get all calendar events with optional date range filtering.
Parent template rows (is_recurring=True, parent_event_id IS NULL,
recurrence_rule IS NOT NULL) are excluded their materialised children
are what get displayed on the calendar.
"""
query = ( query = (
select(CalendarEvent) select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar)) .options(selectinload(CalendarEvent.calendar))
) )
# Exclude parent template rows — they are not directly rendered
query = query.where(
~(
(CalendarEvent.is_recurring == True)
& (CalendarEvent.parent_event_id == None)
& (CalendarEvent.recurrence_rule != None)
)
)
if start: if start:
query = query.where(CalendarEvent.end_datetime >= start) query = query.where(CalendarEvent.end_datetime >= start)
if end: if end:
@ -127,7 +154,7 @@ async def get_events(
response: List[dict] = [_event_to_dict(e) for e in events] response: List[dict] = [_event_to_dict(e) for e in events]
# Fetch Birthdays calendar; only generate virtual events if it exists and is visible # Fetch Birthdays calendar; only generate virtual events if visible
bday_result = await db.execute( bday_result = await db.execute(
select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True) select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True)
) )
@ -153,7 +180,7 @@ async def get_events(
async def create_event( async def create_event(
event: CalendarEventCreate, event: CalendarEventCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session),
): ):
if event.end_datetime < event.start_datetime: if event.end_datetime < event.start_datetime:
raise HTTPException(status_code=400, detail="End datetime must be after start datetime") raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
@ -164,24 +191,58 @@ async def create_event(
if not data.get("calendar_id"): if not data.get("calendar_id"):
data["calendar_id"] = await _get_default_calendar_id(db) data["calendar_id"] = await _get_default_calendar_id(db)
new_event = CalendarEvent(**data) # Serialize RecurrenceRule object to JSON string for DB storage
db.add(new_event) rule_obj = data.pop("recurrence_rule", None)
await db.commit() rule_json: Optional[str] = json.dumps(rule_obj) if rule_obj else None
# Re-fetch with relationship eagerly loaded if rule_json:
result = await db.execute( # Parent template: is_recurring=True, no parent_event_id
select(CalendarEvent) parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True)
.options(selectinload(CalendarEvent.calendar)) db.add(parent)
.where(CalendarEvent.id == new_event.id) await db.flush() # assign parent.id before generating children
)
return result.scalar_one() children = generate_occurrences(parent)
for child in children:
db.add(child)
await db.commit()
# Return the first child so the caller sees a concrete occurrence
# (The parent template is intentionally hidden from list views)
if children:
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == children[0].id)
)
return result.scalar_one()
# Fallback: no children generated (e.g. bad rule), return parent
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == parent.id)
)
return result.scalar_one()
else:
new_event = CalendarEvent(**data, recurrence_rule=None)
db.add(new_event)
await db.commit()
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == new_event.id)
)
return result.scalar_one()
@router.get("/{event_id}", response_model=CalendarEventResponse) @router.get("/{event_id}", response_model=CalendarEventResponse)
async def get_event( async def get_event(
event_id: int, event_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session),
): ):
result = await db.execute( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
@ -201,7 +262,7 @@ async def update_event(
event_id: int, event_id: int,
event_update: CalendarEventUpdate, event_update: CalendarEventUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session),
): ):
result = await db.execute( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
@ -215,31 +276,85 @@ async def update_event(
update_data = event_update.model_dump(exclude_unset=True) update_data = event_update.model_dump(exclude_unset=True)
start = update_data.get("start_datetime", event.start_datetime) # Extract scope before applying fields to the model
end = update_data.get("end_datetime", event.end_datetime) scope: Optional[str] = update_data.pop("edit_scope", None)
if end < start: # Serialize RecurrenceRule → JSON string if present in update payload
rule_obj = update_data.pop("recurrence_rule", None)
if rule_obj is not None:
update_data["recurrence_rule"] = json.dumps(rule_obj) if rule_obj else None
start = update_data.get("start_datetime", event.start_datetime)
end_dt = update_data.get("end_datetime", event.end_datetime)
if end_dt < start:
raise HTTPException(status_code=400, detail="End datetime must be after start datetime") raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
for key, value in update_data.items(): if scope == "this":
setattr(event, key, value) # Update only this occurrence and detach it from the series
for key, value in update_data.items():
setattr(event, key, value)
# Detach from parent so it's an independent event going forward
event.parent_event_id = None
event.is_recurring = False
await db.commit()
await db.commit() elif scope == "this_and_future":
# Delete all future siblings (same parent, original_start >= this event's original_start)
parent_id = event.parent_event_id
this_original_start = event.original_start or event.start_datetime
if parent_id is not None:
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == parent_id,
CalendarEvent.original_start >= this_original_start,
)
)
# Update this event with the new data, making it a new parent
for key, value in update_data.items():
setattr(event, key, value)
event.parent_event_id = None
event.is_recurring = True
event.original_start = None
# If a new recurrence_rule was provided, regenerate children from this point
if event.recurrence_rule:
await db.flush()
children = generate_occurrences(event)
for child in children:
db.add(child)
else:
# Not part of a series — plain update
for key, value in update_data.items():
setattr(event, key, value)
await db.commit()
else:
# No scope — plain update (non-recurring events or full-series metadata)
for key, value in update_data.items():
setattr(event, key, value)
await db.commit()
# Re-fetch to ensure relationship is fresh after commit
result = await db.execute( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar)) .options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id) .where(CalendarEvent.id == event_id)
) )
return result.scalar_one() updated = result.scalar_one_or_none()
if not updated:
# Event was deleted as part of this_and_future; 404 is appropriate
raise HTTPException(status_code=404, detail="Event no longer exists after update")
return updated
@router.delete("/{event_id}", status_code=204) @router.delete("/{event_id}", status_code=204)
async def delete_event( async def delete_event(
event_id: int, event_id: int,
scope: Optional[Literal["this", "this_and_future"]] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session),
): ):
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
event = result.scalar_one_or_none() event = result.scalar_one_or_none()
@ -247,6 +362,29 @@ async def delete_event(
if not event: if not event:
raise HTTPException(status_code=404, detail="Calendar event not found") raise HTTPException(status_code=404, detail="Calendar event not found")
await db.delete(event) if scope == "this":
# Delete just this one occurrence
await db.delete(event)
elif scope == "this_and_future":
parent_id = event.parent_event_id
this_original_start = event.original_start or event.start_datetime
if parent_id is not None:
# Delete this + all future siblings
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == parent_id,
CalendarEvent.original_start >= this_original_start,
)
)
else:
# This event IS the parent — delete it and all children (CASCADE handles children)
await db.delete(event)
else:
# Plain delete (non-recurring; or delete entire series if parent)
await db.delete(event)
await db.commit() await db.commit()
return None return None

View File

@ -1,6 +1,19 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from datetime import datetime from datetime import datetime
from typing import Optional 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): class CalendarEventCreate(BaseModel):
@ -11,7 +24,7 @@ class CalendarEventCreate(BaseModel):
all_day: bool = False all_day: bool = False
color: Optional[str] = None color: Optional[str] = None
location_id: Optional[int] = None location_id: Optional[int] = None
recurrence_rule: Optional[str] = None recurrence_rule: Optional[RecurrenceRule] = None # structured; router serializes to JSON string
is_starred: bool = False is_starred: bool = False
calendar_id: Optional[int] = None # If None, server assigns default calendar calendar_id: Optional[int] = None # If None, server assigns default calendar
@ -24,9 +37,11 @@ class CalendarEventUpdate(BaseModel):
all_day: Optional[bool] = None all_day: Optional[bool] = None
color: Optional[str] = None color: Optional[str] = None
location_id: Optional[int] = None location_id: Optional[int] = None
recurrence_rule: Optional[str] = None recurrence_rule: Optional[RecurrenceRule] = None # structured; router serializes to JSON string
is_starred: Optional[bool] = None is_starred: Optional[bool] = None
calendar_id: Optional[int] = 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): class CalendarEventResponse(BaseModel):
@ -38,12 +53,16 @@ class CalendarEventResponse(BaseModel):
all_day: bool all_day: bool
color: Optional[str] color: Optional[str]
location_id: Optional[int] location_id: Optional[int]
recurrence_rule: Optional[str] recurrence_rule: Optional[str] # raw JSON string from DB; frontend parses
is_starred: bool is_starred: bool
calendar_id: int calendar_id: int
calendar_name: str calendar_name: str
calendar_color: str calendar_color: str
is_virtual: bool = False is_virtual: bool = False
# Recurrence fields
parent_event_id: Optional[int] = None
is_recurring: bool = False
original_start: Optional[datetime] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@ -0,0 +1,176 @@
"""
Recurrence service generates materialized child CalendarEvent ORM objects
from a parent event's recurrence_rule JSON string.
All datetimes are naive (no timezone) to match TIMESTAMP WITHOUT TIME ZONE in DB.
"""
import json
from datetime import datetime, timedelta
from typing import Optional
from app.models.calendar_event import CalendarEvent
def _nth_weekday_of_month(year: int, month: int, weekday: int, week: int) -> Optional[datetime]:
"""
Return the date of the Nth (1-4) occurrence of a given weekday (0=Mon6=Sun)
in a month, or None if it doesn't exist (e.g. 5th Monday in a short month).
"""
# Find the first occurrence of the weekday in the month
first_day = datetime(year, month, 1)
# days_ahead: how many days until the target weekday
days_ahead = (weekday - first_day.weekday()) % 7
first_occurrence = first_day + timedelta(days=days_ahead)
# Advance by (week - 1) full weeks
target = first_occurrence + timedelta(weeks=week - 1)
if target.month != month:
return None
return target
def generate_occurrences(
parent: CalendarEvent,
horizon_days: int = 365,
) -> list[CalendarEvent]:
"""
Parse parent.recurrence_rule (JSON string) and materialise child CalendarEvent
objects up to horizon_days from parent.start_datetime.
Supported rule types:
- every_n_days : {"type": "every_n_days", "interval": N}
- weekly : {"type": "weekly", "weekday": 0-6} (0=Mon, 6=Sun)
- monthly_nth_weekday: {"type": "monthly_nth_weekday", "week": 1-4, "weekday": 0-6}
- monthly_date : {"type": "monthly_date", "day": 1-31}
Returns unsaved ORM objects the caller must add them to the session.
"""
if not parent.recurrence_rule:
return []
try:
rule: dict = json.loads(parent.recurrence_rule)
except (json.JSONDecodeError, TypeError):
return []
rule_type: str = rule.get("type", "")
parent_start: datetime = parent.start_datetime
parent_end: datetime = parent.end_datetime
duration: timedelta = parent_end - parent_start
horizon: datetime = parent_start + timedelta(days=horizon_days)
# Fields to copy verbatim from parent to each child
shared_fields = dict(
title=parent.title,
description=parent.description,
all_day=parent.all_day,
color=parent.color,
location_id=parent.location_id,
is_starred=parent.is_starred,
calendar_id=parent.calendar_id,
# Children do not carry the recurrence_rule; parent is the template
recurrence_rule=None,
parent_event_id=parent.id,
is_recurring=True,
)
occurrences: list[CalendarEvent] = []
def _make_child(occ_start: datetime) -> CalendarEvent:
occ_end = occ_start + duration
return CalendarEvent(
**shared_fields,
start_datetime=occ_start,
end_datetime=occ_end,
original_start=occ_start,
)
if rule_type == "every_n_days":
interval: int = int(rule.get("interval", 1))
if interval < 1:
interval = 1
current = parent_start + timedelta(days=interval)
while current < horizon:
occurrences.append(_make_child(current))
current += timedelta(days=interval)
elif rule_type == "weekly":
weekday: int = int(rule.get("weekday", parent_start.weekday()))
# Start from the week after the parent
days_ahead = (weekday - parent_start.weekday()) % 7
if days_ahead == 0:
days_ahead = 7 # skip the parent's own week
current = parent_start + timedelta(days=days_ahead)
while current < horizon:
occurrences.append(_make_child(current))
current += timedelta(weeks=1)
elif rule_type == "monthly_nth_weekday":
week: int = int(rule.get("week", 1))
weekday = int(rule.get("weekday", parent_start.weekday()))
# Advance month by month
year, month = parent_start.year, parent_start.month
# Move to the next month from the parent
month += 1
if month > 12:
month = 1
year += 1
while True:
target = _nth_weekday_of_month(year, month, weekday, week)
if target is None:
# Skip months where Nth weekday doesn't exist
pass
else:
# Preserve the time-of-day from the parent
occ_start = target.replace(
hour=parent_start.hour,
minute=parent_start.minute,
second=parent_start.second,
microsecond=0,
)
if occ_start >= horizon:
break
occurrences.append(_make_child(occ_start))
month += 1
if month > 12:
month = 1
year += 1
# Safety: if we've passed the horizon year by 2, stop
if year > horizon.year + 1:
break
elif rule_type == "monthly_date":
day: int = int(rule.get("day", parent_start.day))
year, month = parent_start.year, parent_start.month
month += 1
if month > 12:
month = 1
year += 1
while True:
# Some months don't have day 29-31
try:
occ_start = datetime(
year, month, day,
parent_start.hour,
parent_start.minute,
parent_start.second,
)
except ValueError:
# Day out of range for this month — skip
month += 1
if month > 12:
month = 1
year += 1
if year > horizon.year + 1:
break
continue
if occ_start >= horizon:
break
occurrences.append(_make_child(occ_start))
month += 1
if month > 12:
month = 1
year += 1
if year > horizon.year + 1:
break
return occurrences