Files
state-hub/api/routers/topics.py
tegwick b832032cc3 feat(api): dashboard poll optimisation — T1, T2, T3
T1: Cache-Control max-age=60 on /topics/, /repos/, /domains/ list endpoints
    so repeated dashboard polls within a minute are served from browser cache.

T2: ETag middleware (md5 hash) on all JSON GET responses with conditional-GET
    (304 Not Modified) support; If-None-Match and ETag added to CORS headers.
    ETag registered inside CORS so 304s automatically carry CORS headers.

T3: GET /state/deps — lightweight dep-graph endpoint returning open workstreams
    with depends_on/blocks edges only, skipping the 10-table full-summary query.
    Prerequisite for T4 (switching workstreams.md and dependencies.md off /state/summary).

Workplan: CUST-WP-0039-dashboard-poll-optimization.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:26:30 +02:00

103 lines
3.4 KiB
Python

import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.domain import Domain
from api.models.topic import Topic, TopicStatus
from api.schemas.topic import TopicCreate, TopicRead, TopicUpdate, TopicWithWorkstreams
router = APIRouter(prefix="/topics", tags=["topics"])
async def _resolve_domain_id(domain_slug: str, session: AsyncSession) -> uuid.UUID:
"""Resolve a domain slug to its UUID. Raises 404 if not found."""
result = await session.execute(select(Domain).where(Domain.slug == domain_slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{domain_slug}' not found")
return domain.id
@router.get("/", response_model=list[TopicRead])
async def list_topics(
response: Response,
status: TopicStatus | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Topic]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(Topic)
if status:
q = q.where(Topic.status == status)
q = q.order_by(Topic.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=TopicRead, status_code=status.HTTP_201_CREATED)
async def create_topic(
body: TopicCreate,
session: AsyncSession = Depends(get_session),
) -> Topic:
domain_id = await _resolve_domain_id(body.domain, session)
existing = await session.execute(select(Topic).where(Topic.slug == body.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Topic slug '{body.slug}' already exists")
topic = Topic(
slug=body.slug,
title=body.title,
description=body.description,
domain_id=domain_id,
status=body.status,
)
session.add(topic)
await session.commit()
await session.refresh(topic)
return topic
@router.get("/{topic_id}", response_model=TopicWithWorkstreams)
async def get_topic(
topic_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Topic:
topic = await session.get(Topic, topic_id)
if topic is None:
raise HTTPException(status_code=404, detail="Topic not found")
return topic
@router.patch("/{topic_id}", response_model=TopicRead)
async def update_topic(
topic_id: uuid.UUID,
body: TopicUpdate,
session: AsyncSession = Depends(get_session),
) -> Topic:
topic = await session.get(Topic, topic_id)
if topic is None:
raise HTTPException(status_code=404, detail="Topic not found")
updates = body.model_dump(exclude_unset=True)
if "domain" in updates:
topic.domain_id = await _resolve_domain_id(updates.pop("domain"), session)
for field, value in updates.items():
setattr(topic, field, value)
await session.commit()
await session.refresh(topic)
return topic
@router.delete("/{topic_id}", response_model=TopicRead)
async def archive_topic(
topic_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Topic:
topic = await session.get(Topic, topic_id)
if topic is None:
raise HTTPException(status_code=404, detail="Topic not found")
topic.status = TopicStatus.archived
await session.commit()
await session.refresh(topic)
return topic