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"}