from datetime import UTC, datetime, timedelta from uuid import UUID from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from . import calendar as calendar_service from .auth import current_user from .db import get_db from .models import CalendarSubscription, User, utcnow router = APIRouter(prefix="/api/v1", tags=["calendar"]) MAX_WINDOW = timedelta(days=366) class SubscriptionCreate(BaseModel): name: str = Field(min_length=1, max_length=120) url: str = Field(min_length=1, max_length=2000) color: str = Field(default="#f15a29", pattern=r"^#[0-9A-Fa-f]{6}$") enabled: bool = True @field_validator("name") @classmethod def clean_name(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("name cannot be blank") return value class SubscriptionUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) url: str | None = Field(default=None, min_length=1, max_length=2000) color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$") enabled: bool | None = None @field_validator("name") @classmethod def clean_name(cls, value: str | None) -> str | None: if value is None: return None value = value.strip() if not value: raise ValueError("name cannot be blank") return value @model_validator(mode="after") def reject_nulls(self): for field in self.model_fields_set: if getattr(self, field) is None: raise ValueError(f"{field} cannot be null") return self class SubscriptionOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID name: str url: str color: str enabled: bool refreshed_at: datetime | None last_error: str | None stale: bool def _out(row: CalendarSubscription) -> dict: return { "id": row.id, "name": row.name, "url": row.url, "color": row.color, "enabled": row.enabled, "refreshed_at": row.refreshed_at, "last_error": row.last_error, "stale": bool(row.last_error and row.ics_cache), } async def _owned(db: AsyncSession, user_id: UUID, subscription_id: UUID) -> CalendarSubscription: row = await db.scalar(select(CalendarSubscription).where( CalendarSubscription.id == subscription_id, CalendarSubscription.user_id == user_id, )) if row is None: raise HTTPException(404, "calendar subscription not found") return row async def _refresh(db: AsyncSession, row: CalendarSubscription) -> None: try: result = await __import__("asyncio").to_thread( calendar_service.fetch_calendar, row.url, etag=row.etag, last_modified=row.last_modified ) if result.not_modified: if not row.ics_cache: raise HTTPException(502, "calendar returned not modified without cache") elif result.content is not None: # Parse before replacing a known-good cache. calendar_service.parse_ics_events( result.content, row.name, row.color, datetime.now(UTC) - timedelta(days=1), datetime.now(UTC) + timedelta(days=1), "UTC", ) row.ics_cache = result.content.decode("utf-8-sig") row.etag = result.etag row.last_modified = result.last_modified row.refreshed_at = utcnow() row.last_error = None except Exception as exc: row.last_error = exc.detail if isinstance(exc, HTTPException) else str(exc) if not row.ics_cache: await db.rollback() raise HTTPException(502, row.last_error) from exc await db.commit() @router.get("/calendar-subscriptions", response_model=list[SubscriptionOut]) async def list_subscriptions(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): rows = (await db.scalars(select(CalendarSubscription).where( CalendarSubscription.user_id == user.id ).order_by(CalendarSubscription.created_at, CalendarSubscription.id))).all() return [_out(row) for row in rows] @router.post("/calendar-subscriptions", response_model=SubscriptionOut, status_code=201) async def create_subscription( payload: SubscriptionCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): calendar_service.validate_calendar_url(payload.url) row = CalendarSubscription(user_id=user.id, **payload.model_dump()) db.add(row) await db.flush() await _refresh(db, row) await db.refresh(row) return _out(row) @router.patch("/calendar-subscriptions/{subscription_id}", response_model=SubscriptionOut) async def update_subscription( subscription_id: UUID, payload: SubscriptionUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): row = await _owned(db, user.id, subscription_id) changes = payload.model_dump(exclude_unset=True) if "url" in changes: calendar_service.validate_calendar_url(changes["url"]) if changes["url"] != row.url: row.ics_cache = row.etag = row.last_modified = row.refreshed_at = row.last_error = None for key, value in changes.items(): setattr(row, key, value) await db.commit() await db.refresh(row) return _out(row) @router.delete("/calendar-subscriptions/{subscription_id}", status_code=204) async def delete_subscription( subscription_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): row = await _owned(db, user.id, subscription_id) await db.delete(row) await db.commit() return Response(status_code=204) @router.post("/calendar-subscriptions/{subscription_id}/refresh", response_model=SubscriptionOut) async def refresh_subscription( subscription_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): row = await _owned(db, user.id, subscription_id) await _refresh(db, row) await db.refresh(row) return _out(row) @router.get("/calendar-events") async def calendar_events( start: datetime = Query(), end: datetime = Query(), user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): if start.tzinfo is None or end.tzinfo is None or end <= start or end - start > MAX_WINDOW: raise HTTPException(422, "start/end must be timezone-aware and span at most 366 days") try: ZoneInfo(user.timezone) except ZoneInfoNotFoundError as exc: raise HTTPException(422, "user timezone is invalid") from exc rows = (await db.scalars(select(CalendarSubscription).where( CalendarSubscription.user_id == user.id, CalendarSubscription.enabled.is_(True), ).order_by(CalendarSubscription.created_at, CalendarSubscription.id))).all() events = [] sources = [] for row in rows: if not row.ics_cache: await _refresh(db, row) try: parsed = calendar_service.parse_ics_events( row.ics_cache or "", row.name, row.color, start, end, user.timezone ) events.extend(parsed) except ValueError as exc: row.last_error = str(exc) await db.commit() sources.append({"id": row.id, "name": row.name, "stale": bool(row.last_error)}) events.sort(key=lambda item: (item["starts_at"], item["title"], item["id"])) return {"events": events, "sources": sources}