- 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>
253 lines
8.3 KiB
Python
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
|