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