[verified] feat: add external calendar subscriptions
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user