UMBRA/backend/app/routers/events.py
Kyle Pope 093bceed06 Add multi-calendar backend support with virtual birthday events
- New Calendar model and calendars table with system/default flags
- Alembic migration 006: creates calendars, seeds Personal+Birthdays, migrates existing events
- CalendarEvent model gains calendar_id FK and calendar_name/calendar_color properties
- Updated CalendarEventCreate/Response schemas to include calendar fields
- New /api/calendars CRUD router (blocks system calendar deletion/rename)
- Events router: selectinload on all queries, default-calendar assignment on POST, virtual birthday event generation from People with birthdays when Birthdays calendar is visible

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

253 lines
8.3 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from typing import Optional, List, Any
from datetime import date, datetime, timedelta
from app.database import get_db
from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
from app.models.person import Person
from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
def _event_to_dict(event: CalendarEvent) -> dict:
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
return {
"id": event.id,
"title": event.title,
"description": event.description,
"start_datetime": event.start_datetime,
"end_datetime": event.end_datetime,
"all_day": event.all_day,
"color": event.color,
"location_id": event.location_id,
"recurrence_rule": event.recurrence_rule,
"is_starred": event.is_starred,
"calendar_id": event.calendar_id,
"calendar_name": event.calendar.name if event.calendar else "",
"calendar_color": event.calendar.color if event.calendar else "",
"is_virtual": False,
"created_at": event.created_at,
"updated_at": event.updated_at,
}
def _birthday_events_for_range(
people: list,
bday_calendar_id: int,
bday_calendar_color: str,
start: Optional[date],
end: Optional[date],
) -> list[dict]:
"""Generate virtual all-day birthday events for each person within the date range."""
virtual_events = []
now = datetime.now()
range_start = start or date(now.year - 1, 1, 1)
range_end = end or date(now.year + 2, 12, 31)
for person in people:
if not person.birthday:
continue
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):
try:
bday_this_year = bday.replace(year=year)
except ValueError:
# Feb 29 on non-leap year — skip
continue
if bday_this_year < range_start or bday_this_year > range_end:
continue
start_dt = datetime(bday_this_year.year, bday_this_year.month, bday_this_year.day, 0, 0, 0)
end_dt = start_dt + timedelta(days=1)
age = year - bday.year
virtual_events.append({
"id": f"bday-{person.id}-{year}",
"title": f"{person.name}'s Birthday" + (f" ({age})" if age > 0 else ""),
"description": None,
"start_datetime": start_dt,
"end_datetime": end_dt,
"all_day": True,
"color": bday_calendar_color,
"location_id": None,
"recurrence_rule": None,
"is_starred": False,
"calendar_id": bday_calendar_id,
"calendar_name": "Birthdays",
"calendar_color": bday_calendar_color,
"is_virtual": True,
"created_at": start_dt,
"updated_at": start_dt,
})
return virtual_events
async def _get_default_calendar_id(db: AsyncSession) -> int:
"""Return the id of the default calendar, raising 500 if not found."""
result = await db.execute(select(Calendar).where(Calendar.is_default == True))
default = result.scalar_one_or_none()
if not default:
raise HTTPException(status_code=500, detail="No default calendar configured")
return default.id
@router.get("/", response_model=None)
async def get_events(
start: Optional[date] = Query(None),
end: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
) -> List[Any]:
"""Get all calendar events with optional date range filtering, including virtual birthday events."""
query = (
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
)
if start:
query = query.where(CalendarEvent.end_datetime >= start)
if end:
query = query.where(CalendarEvent.start_datetime <= end)
query = query.order_by(CalendarEvent.start_datetime.asc())
result = await db.execute(query)
events = result.scalars().all()
response: List[dict] = [_event_to_dict(e) for e in events]
# Fetch Birthdays calendar; only generate virtual events if it exists and is visible
bday_result = await db.execute(
select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True)
)
bday_calendar = bday_result.scalar_one_or_none()
if bday_calendar and bday_calendar.is_visible:
people_result = await db.execute(select(Person).where(Person.birthday.isnot(None)))
people = people_result.scalars().all()
virtual = _birthday_events_for_range(
people=people,
bday_calendar_id=bday_calendar.id,
bday_calendar_color=bday_calendar.color,
start=start,
end=end,
)
response.extend(virtual)
return response
@router.post("/", response_model=CalendarEventResponse, status_code=201)
async def create_event(
event: CalendarEventCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
if event.end_datetime < event.start_datetime:
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
data = event.model_dump()
# Resolve calendar_id to default if not provided
if not data.get("calendar_id"):
data["calendar_id"] = await _get_default_calendar_id(db)
new_event = CalendarEvent(**data)
db.add(new_event)
await db.commit()
# Re-fetch with relationship eagerly loaded
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)
async def get_event(
event_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
return event
@router.put("/{event_id}", response_model=CalendarEventResponse)
async def update_event(
event_id: int,
event_update: CalendarEventUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
update_data = event_update.model_dump(exclude_unset=True)
start = update_data.get("start_datetime", event.start_datetime)
end = update_data.get("end_datetime", event.end_datetime)
if end < start:
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
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(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
)
return result.scalar_one()
@router.delete("/{event_id}", status_code=204)
async def delete_event(
event_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
await db.delete(event)
await db.commit()
return None