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
This commit is contained in:
curo1305
2026-05-23 20:15:44 +02:00
parent b28bb01995
commit 5950a3f5c2
5 changed files with 292 additions and 55 deletions
+32 -1
View File
@@ -32,7 +32,7 @@ from pydantic import BaseModel, EmailStr, field_validator
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Quota, RefreshToken, User
from db.models import Quota, RefreshToken, Topic, User
from deps.auth import get_current_admin
from deps.db import get_db
from services.auth import hash_password, revoke_all_refresh_tokens
@@ -127,6 +127,14 @@ class AiConfigUpdate(BaseModel):
ai_model: Optional[str] = None
class SystemTopicCreate(BaseModel):
"""Request model for admin system topic creation (D-09)."""
name: str
description: str = ""
color: str = "#6366f1"
# ── Endpoints ─────────────────────────────────────────────────────────────────
@@ -378,3 +386,26 @@ async def update_ai_config(
"ai_provider": user.ai_provider,
"ai_model": user.ai_model,
}
@router.post("/topics", status_code=status.HTTP_201_CREATED)
async def create_system_topic(
body: SystemTopicCreate,
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
"""Create a system topic visible to all users (D-09, DOC-04).
System topics have user_id = NULL, making them visible to every user as
defaults in their topic namespace. Only admins can create system topics.
Regular users create per-user topics via POST /api/topics.
Deduplication: case-insensitive match within the system namespace (user_id IS NULL).
Returns the existing system topic if one with the same name already exists.
"""
from services import storage # noqa: PLC0415
topic = await storage.create_topic(
session, body.name, body.description, body.color, user_id=None
)
return topic
+74 -8
View File
@@ -1,9 +1,14 @@
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
@@ -27,17 +32,37 @@ class SuggestRequest(BaseModel):
@router.get("")
async def list_topics(session: AsyncSession = Depends(get_db)):
topics = await storage.load_topics(session)
counts = await storage.topic_doc_counts(session)
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)):
topic = await storage.create_topic(session, body.name, body.description, body.color)
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
@@ -47,7 +72,22 @@ 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,
@@ -57,13 +97,31 @@ async def update_topic(
)
if topic is None:
raise HTTPException(404, "Topic not found")
counts = await storage.topic_doc_counts(session)
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)):
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")
@@ -71,7 +129,15 @@ async def delete_topic(topic_id: str, session: AsyncSession = Depends(get_db)):
@router.post("/suggest")
async def suggest_topics(body: SuggestRequest, session: AsyncSession = Depends(get_db)):
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")