Files
kite/backend/api/topics.py
T
curo1305 87a32b7ee8 feat(phase-4): complete UX redesign — FileManagerView, FolderTreeItem, test suite, and all Phase 4 fixes
Adds the unified file manager view (Windows Explorer-style), collapsible
folder tree sidebar item, full vitest test suite (55 tests, 4 files), and
commits all Phase 4 backend/frontend fixes that were staged but uncommitted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:10:52 +02:00

157 lines
4.7 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 Document, 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.
D-16 / SEC-IDOR: asserts document ownership — cross-user access returns 404
to prevent document ID enumeration (same pattern as documents router).
"""
try:
uid = uuid.UUID(body.document_id)
except ValueError:
raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
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}