from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Path, Query from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import func, select, update from typing import List from app.database import get_db from app.models.calendar import Calendar from app.models.calendar_event import CalendarEvent from app.models.calendar_member import CalendarMember from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse from app.services.calendar_sharing import require_permission from app.routers.auth import get_current_user from app.models.user import User router = APIRouter() @router.get("/", response_model=List[CalendarResponse]) async def get_calendars( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): result = await db.execute( select(Calendar) .where(Calendar.user_id == current_user.id) .order_by(Calendar.is_default.desc(), Calendar.name.asc()) ) calendars = result.scalars().all() # Populate member_count for shared calendars cal_ids = [c.id for c in calendars if c.is_shared] count_map: dict[int, int] = {} if cal_ids: counts = await db.execute( select(CalendarMember.calendar_id, func.count()) .where( CalendarMember.calendar_id.in_(cal_ids), CalendarMember.status == "accepted", ) .group_by(CalendarMember.calendar_id) ) count_map = dict(counts.all()) return [ CalendarResponse.model_validate(c, from_attributes=True).model_copy( update={"member_count": count_map.get(c.id, 0)} ) for c in calendars ] @router.post("/", response_model=CalendarResponse, status_code=201) async def create_calendar( calendar: CalendarCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): new_calendar = Calendar( name=calendar.name, color=calendar.color, is_default=False, is_system=False, is_visible=True, user_id=current_user.id, ) db.add(new_calendar) await db.commit() await db.refresh(new_calendar) return new_calendar @router.put("/{calendar_id}", response_model=CalendarResponse) async def update_calendar( calendar_id: int = Path(ge=1, le=2147483647), calendar_update: CalendarUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): result = await db.execute( select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id) ) calendar = result.scalar_one_or_none() if not calendar: raise HTTPException(status_code=404, detail="Calendar not found") update_data = calendar_update.model_dump(exclude_unset=True) # System calendars: allow visibility toggle but block name changes if calendar.is_system and "name" in update_data: raise HTTPException(status_code=400, detail="Cannot rename system calendars") for key, value in update_data.items(): setattr(calendar, key, value) await db.commit() await db.refresh(calendar) return calendar @router.delete("/{calendar_id}", status_code=204) async def delete_calendar( calendar_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): result = await db.execute( select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id) ) calendar = result.scalar_one_or_none() if not calendar: raise HTTPException(status_code=404, detail="Calendar not found") if calendar.is_system: raise HTTPException(status_code=400, detail="Cannot delete system calendars") if calendar.is_default: raise HTTPException(status_code=400, detail="Cannot delete the default calendar") # Reassign all events on this calendar to the user's default calendar default_result = await db.execute( select(Calendar).where( Calendar.user_id == current_user.id, Calendar.is_default == True, ) ) default_calendar = default_result.scalar_one_or_none() if default_calendar: await db.execute( update(CalendarEvent) .where(CalendarEvent.calendar_id == calendar_id) .values(calendar_id=default_calendar.id) ) await db.delete(calendar) await db.commit() return None # ────────────────────────────────────────────── # DELTA POLLING # ────────────────────────────────────────────── class CalendarPollResponse(BaseModel): has_changes: bool calendar_updated_at: str | None = None changed_event_ids: list[int] = [] @router.get("/{calendar_id}/poll", response_model=CalendarPollResponse) async def poll_calendar( calendar_id: int = Path(ge=1, le=2147483647), since: str = Query(..., description="ISO timestamp to check for changes since"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Lightweight poll endpoint — returns changed event IDs since timestamp.""" await require_permission(db, calendar_id, current_user.id, "read_only") try: since_dt = datetime.fromisoformat(since) except ValueError: raise HTTPException(status_code=400, detail="Invalid ISO timestamp") # Check calendar-level update cal_result = await db.execute( select(Calendar.updated_at).where(Calendar.id == calendar_id) ) calendar_updated = cal_result.scalar_one_or_none() if not calendar_updated: raise HTTPException(status_code=404, detail="Calendar not found") calendar_changed = calendar_updated > since_dt # Check event-level changes using the ix_events_calendar_updated index event_result = await db.execute( select(CalendarEvent.id).where( CalendarEvent.calendar_id == calendar_id, CalendarEvent.updated_at > since_dt, ) ) changed_event_ids = [r[0] for r in event_result.all()] has_changes = calendar_changed or len(changed_event_ids) > 0 return CalendarPollResponse( has_changes=has_changes, calendar_updated_at=calendar_updated.isoformat() if calendar_updated else None, changed_event_ids=changed_event_ids, )