Files
kite/backend/api/topics.py
T
curo1305 5950a3f5c2 feat(03-03): wire get_current_user into /api/topics/*; add load_topics_for_user; POST /api/admin/topics
- api/topics.py: add get_current_user dep to all 5 handlers (list, create, update, delete, suggest)
- list_topics: uses load_topics_for_user (system topics + user's own) with user-scoped doc counts
- create_topic: passes user_id=current_user.id (never creates system topics via regular endpoint)
- update_topic/delete_topic: ownership assertion — system topics and other users' topics return 404
- api/admin.py: add SystemTopicCreate model + POST /api/admin/topics (user_id=NULL, admin-only)
- services/storage.py: add or_ import; load_topics_for_user (D-17); create_topic gains user_id param with namespace-scoped dedup; topic_doc_counts gains optional user_id for user-scoped counts; add load_topics_for_user to __all__
- services/classifier.py: replace load_topics with load_topics_for_user(doc.user_id); pass user_id=doc.user_id to create_topic for AI-suggested topics (D-11)
- Tests: update all topic tests to pass auth headers; implement test_topic_namespace, test_admin_create_system_topic, test_regular_user_cannot_create_system_topic, test_topics_require_auth
2026-05-23 20:15:44 +02:00

149 lines
4.4 KiB
Python

from __future__ import annotations
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Topic, User
from deps.auth import get_current_user
from deps.db import get_db
from services import classifier, storage
router = APIRouter(prefix="/api/topics", tags=["topics"])
class TopicCreate(BaseModel):
name: str
description: str = ""
color: str = "#6366f1"
class TopicUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
class SuggestRequest(BaseModel):
document_id: str
@router.get("")
async def list_topics(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List topics visible to the current user: system topics + their own topics.
D-08 + D-17: namespace-scoped query — returns system topics (user_id IS NULL)
+ the authenticated user's own topics. Never returns other users' topics.
"""
topics = await storage.load_topics_for_user(session, user_id=current_user.id)
counts = await storage.topic_doc_counts(session, user_id=current_user.id)
for t in topics:
t["doc_count"] = counts.get(t["name"], 0)
return {"topics": topics}
@router.post("")
async def create_topic(
body: TopicCreate,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a per-user topic (user_id = current_user.id).
D-09: regular users can only create topics in their own namespace.
For system topics (user_id = NULL), use POST /api/admin/topics.
Deduplication is scoped to the user's namespace.
"""
topic = await storage.create_topic(
session, body.name, body.description, body.color, user_id=current_user.id
)
topic["doc_count"] = 0
return topic
@router.patch("/{topic_id}")
async def update_topic(
topic_id: str,
body: TopicUpdate,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a user-owned topic.
D-09: users can only edit their own topics. System topics (user_id IS NULL)
and other users' topics return 404 (not 403) to avoid information leakage.
"""
try:
uid = uuid.UUID(topic_id)
except ValueError:
raise HTTPException(404, "Topic not found")
t = await session.get(Topic, uid)
if t is None or t.user_id != current_user.id:
raise HTTPException(404, "Topic not found")
topic = await storage.update_topic(
session,
topic_id,
name=body.name,
description=body.description,
color=body.color,
)
if topic is None:
raise HTTPException(404, "Topic not found")
counts = await storage.topic_doc_counts(session, user_id=current_user.id)
topic["doc_count"] = counts.get(topic["name"], 0)
return topic
@router.delete("/{topic_id}")
async def delete_topic(
topic_id: str,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a user-owned topic.
D-09: users can only delete their own topics. System topics (user_id IS NULL)
and other users' topics return 404 (not 403) to avoid information leakage.
"""
try:
uid = uuid.UUID(topic_id)
except ValueError:
raise HTTPException(404, "Topic not found")
t = await session.get(Topic, uid)
if t is None or t.user_id != current_user.id:
raise HTTPException(404, "Topic not found")
name = await storage.delete_topic(session, topic_id)
if name is None:
raise HTTPException(404, "Topic not found")
return {"success": True, "removed_from_documents": True}
@router.post("/suggest")
async def suggest_topics(
body: SuggestRequest,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Suggest topics for a document using AI.
D-11: classifier uses the user's namespace (system + user topics) for suggestions.
"""
meta = await storage.get_metadata(session, body.document_id)
if meta is None:
raise HTTPException(404, "Document not found")
try:
suggestions = await classifier.suggest_topics_for_document(session, body.document_id)
except Exception as e:
raise HTTPException(500, f"Suggestion failed: {e}")
return {"suggested": suggestions}