theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

topics.py (5334B)


      1 """Topic CRUD routes with session assignment."""
      2 from __future__ import annotations
      3 
      4 import time
      5 import uuid
      6 from typing import Any
      7 
      8 from fastapi import APIRouter, HTTPException
      9 
     10 from app.db import Database
     11 from app.models import TopicCreate, TopicResponse, TopicUpdate
     12 
     13 router = APIRouter(prefix="/api/topics", tags=["topics"])
     14 
     15 _db: Database | None = None
     16 
     17 
     18 def get_db() -> Database:  # pragma: no mutate: block
     19     """Return the module-level database instance."""
     20     if _db is None:
     21         raise RuntimeError("Database not initialized")
     22     return _db
     23 
     24 
     25 def set_db(db: Database) -> None:  # pragma: no mutate: block
     26     """Inject the database instance (called from lifespan)."""
     27     global _db  # noqa: PLW0603
     28     _db = db
     29 
     30 
     31 async def _fetch_topic(topic_id: str) -> dict[str, Any] | None:  # pragma: no mutate: block
     32     """Fetch a single topic by id."""
     33     rows = await get_db().fetch_all(
     34         "SELECT id, name, color, sort_order, created_at FROM topics WHERE id = ?",
     35         (topic_id,),
     36     )
     37     return rows[0] if rows else None
     38 
     39 
     40 async def _count_sessions(topic_id: str) -> int:  # pragma: no mutate: block
     41     """Count sessions assigned to a topic."""
     42     rows = await get_db().fetch_all(
     43         "SELECT COUNT(*) as cnt FROM topic_sessions WHERE topic_id = ?",
     44         (topic_id,),
     45     )
     46     return rows[0]["cnt"] if rows else 0
     47 
     48 
     49 async def _topic_response(row: dict[str, Any]) -> TopicResponse:  # pragma: no mutate: block
     50     """Build a TopicResponse with session_count."""
     51     count = await _count_sessions(row["id"])
     52     return TopicResponse(
     53         id=row["id"],
     54         name=row["name"],
     55         color=row["color"],
     56         sort_order=row["sort_order"],
     57         created_at=row["created_at"],
     58         session_count=count,
     59     )
     60 
     61 
     62 @router.post("", response_model=TopicResponse)
     63 async def create_topic(payload: TopicCreate) -> TopicResponse:  # pragma: no mutate: block
     64     """Create a new topic."""
     65     topic_id = uuid.uuid4().hex[:12]
     66     now = int(time.time())
     67     await get_db().execute(
     68         "INSERT INTO topics (id, name, color, sort_order, created_at) VALUES (?, ?, ?, 0, ?)",
     69         (topic_id, payload.name, payload.color or "#6366f1", now),
     70     )
     71     row = await _fetch_topic(topic_id)
     72     assert row is not None
     73     return await _topic_response(row)
     74 
     75 
     76 @router.get("", response_model=list[TopicResponse])
     77 async def list_topics() -> list[TopicResponse]:  # pragma: no mutate: block
     78     """List all topics ordered by sort_order."""
     79     rows = await get_db().fetch_all(
     80         "SELECT id, name, color, sort_order, created_at FROM topics ORDER BY sort_order"
     81     )
     82     return [await _topic_response(r) for r in rows]
     83 
     84 
     85 @router.patch("/{topic_id}", response_model=TopicResponse)
     86 async def update_topic(topic_id: str, payload: TopicUpdate) -> TopicResponse:  # pragma: no mutate: block
     87     """Update a topic's name, color, or sort_order."""
     88     existing = await _fetch_topic(topic_id)
     89     if existing is None:
     90         raise HTTPException(status_code=404, detail="Topic not found")
     91     updates: list[str] = []
     92     params: list[Any] = []
     93     if payload.name is not None:
     94         updates.append("name = ?")
     95         params.append(payload.name)
     96     if payload.color is not None:
     97         updates.append("color = ?")
     98         params.append(payload.color)
     99     if payload.sort_order is not None:
    100         updates.append("sort_order = ?")
    101         params.append(payload.sort_order)
    102     if updates:
    103         params.append(topic_id)
    104         await get_db().execute(
    105             f"UPDATE topics SET {', '.join(updates)} WHERE id = ?",
    106             tuple(params),
    107         )
    108     row = await _fetch_topic(topic_id)
    109     assert row is not None
    110     return await _topic_response(row)
    111 
    112 
    113 @router.delete("/{topic_id}")
    114 async def delete_topic(topic_id: str) -> dict[str, str]:  # pragma: no mutate: block
    115     """Delete a topic; associated sessions become unsorted."""
    116     existing = await _fetch_topic(topic_id)
    117     if existing is None:
    118         raise HTTPException(status_code=404, detail="Topic not found")
    119     await get_db().execute(
    120         "DELETE FROM topic_sessions WHERE topic_id = ?", (topic_id,)
    121     )
    122     await get_db().execute(
    123         "DELETE FROM topics WHERE id = ?", (topic_id,)
    124     )
    125     return {"ok": "deleted"}
    126 
    127 
    128 @router.post("/{topic_id}/sessions")
    129 async def assign_session(topic_id: str, payload: dict) -> dict[str, str]:  # pragma: no mutate: block
    130     """Assign a session to a topic."""
    131     existing = await _fetch_topic(topic_id)
    132     if existing is None:
    133         raise HTTPException(status_code=404, detail="Topic not found")
    134     session_id = payload["session_id"]
    135     now = int(time.time())
    136     await get_db().execute(
    137         "INSERT OR REPLACE INTO topic_sessions (topic_id, session_id, assigned_at) VALUES (?, ?, ?)",
    138         (topic_id, session_id, now),
    139     )
    140     return {"ok": "assigned"}
    141 
    142 
    143 @router.delete("/{topic_id}/sessions/{session_id}")
    144 async def unassign_session(topic_id: str, session_id: str) -> dict[str, str]:  # pragma: no mutate: block
    145     """Remove a session from a topic."""
    146     existing = await _fetch_topic(topic_id)
    147     if existing is None:
    148         raise HTTPException(status_code=404, detail="Topic not found")
    149     await get_db().execute(
    150         "DELETE FROM topic_sessions WHERE topic_id = ? AND session_id = ?",
    151         (topic_id, session_id),
    152     )
    153     return {"ok": "unassigned"}