Phase 1 MVP - Complete implementation
- Auth: register, login, JWT with refresh tokens, blocklist - Projects/Folders/Documents CRUD with soft deletes - Tags CRUD and assignment - FTS5 search with highlights and tag filtering - ADR-001, ADR-002, ADR-003 compliant - Security fixes applied (JWT_SECRET_KEY, exception handler, cookie secure) - 25 tests passing
This commit is contained in:
435
app/routers/documents.py
Normal file
435
app/routers/documents.py
Normal file
@@ -0,0 +1,435 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.document import Document
|
||||
from app.models.folder import Folder
|
||||
from app.models.project import Project
|
||||
from app.models.tag import DocumentTag, Tag
|
||||
from app.routers.auth import get_current_agent
|
||||
from app.schemas.document import (
|
||||
DocumentBriefResponse,
|
||||
DocumentContentUpdate,
|
||||
DocumentCreate,
|
||||
DocumentListResponse,
|
||||
DocumentResponse,
|
||||
DocumentUpdate,
|
||||
TagInfo,
|
||||
)
|
||||
from app.schemas.tag import DocumentTagsAssign
|
||||
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
|
||||
|
||||
def build_doc_path(project_id: str, doc_id: str, folder_id: str | None, folder_path: str | None) -> str:
|
||||
if folder_id and folder_path:
|
||||
return f"{folder_path}/{doc_id}"
|
||||
return f"/{project_id}/{doc_id}"
|
||||
|
||||
|
||||
async def get_document_tags(db: AsyncSession, doc_id: str) -> list[TagInfo]:
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT t.id, t.name, t.color
|
||||
FROM active_tags t
|
||||
JOIN document_tags dt ON t.id = dt.tag_id
|
||||
WHERE dt.document_id = :doc_id
|
||||
"""),
|
||||
{"doc_id": doc_id}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [TagInfo(id=r.id, name=r.name, color=r.color) for r in rows]
|
||||
|
||||
|
||||
async def document_to_response(db: AsyncSession, doc: Document) -> DocumentResponse:
|
||||
tags = await get_document_tags(db, doc.id)
|
||||
return DocumentResponse(
|
||||
id=doc.id,
|
||||
title=doc.title,
|
||||
content=doc.content,
|
||||
project_id=doc.project_id,
|
||||
folder_id=doc.folder_id,
|
||||
path=doc.path,
|
||||
tags=tags,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/projects/{project_id}/documents", response_model=DocumentListResponse)
|
||||
async def list_documents(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.project_id == project_id,
|
||||
Document.is_deleted == False,
|
||||
).order_by(Document.created_at.desc())
|
||||
)
|
||||
docs = result.scalars().all()
|
||||
|
||||
responses = []
|
||||
for doc in docs:
|
||||
tags = await get_document_tags(db, doc.id)
|
||||
responses.append(DocumentBriefResponse(
|
||||
id=doc.id,
|
||||
title=doc.title,
|
||||
project_id=doc.project_id,
|
||||
folder_id=doc.folder_id,
|
||||
path=doc.path,
|
||||
tags=tags,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
))
|
||||
|
||||
return DocumentListResponse(documents=responses)
|
||||
|
||||
|
||||
@router.post("/api/v1/projects/{project_id}/documents", response_model=DocumentResponse, status_code=201)
|
||||
async def create_document(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
payload: DocumentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
folder_path = None
|
||||
if payload.folder_id:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == payload.folder_id,
|
||||
Folder.project_id == project_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=400, detail="Folder not found")
|
||||
folder_path = folder.path
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
path = build_doc_path(project_id, doc_id, payload.folder_id, folder_path)
|
||||
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
project_id=project_id,
|
||||
folder_id=payload.folder_id,
|
||||
path=path,
|
||||
)
|
||||
db.add(doc)
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.get("/api/v1/documents/{document_id}", response_model=DocumentResponse)
|
||||
async def get_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.put("/api/v1/documents/{document_id}", response_model=DocumentResponse)
|
||||
async def update_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
payload: DocumentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
if payload.title is not None:
|
||||
doc.title = payload.title
|
||||
if payload.folder_id is not None:
|
||||
if payload.folder_id:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == payload.folder_id,
|
||||
Folder.project_id == doc.project_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=400, detail="Folder not found")
|
||||
doc.path = f"{folder.path}/{doc.id}"
|
||||
else:
|
||||
doc.path = f"/{doc.project_id}/{doc.id}"
|
||||
doc.folder_id = payload.folder_id
|
||||
|
||||
doc.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.delete("/api/v1/documents/{document_id}", status_code=204)
|
||||
async def delete_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
doc.is_deleted = True
|
||||
doc.deleted_at = datetime.utcnow()
|
||||
doc.deleted_by = agent.id
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
@router.put("/api/v1/documents/{document_id}/content", response_model=DocumentResponse)
|
||||
async def update_document_content(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
payload: DocumentContentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
doc.content = payload.content
|
||||
doc.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.post("/api/v1/documents/{document_id}/restore", response_model=DocumentResponse)
|
||||
async def restore_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == True,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
doc.is_deleted = False
|
||||
doc.deleted_at = None
|
||||
doc.deleted_by = None
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.post("/api/v1/documents/{document_id}/tags", status_code=204)
|
||||
async def assign_tags(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
payload: DocumentTagsAssign,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
for tag_id in payload.tag_ids:
|
||||
tag_result = await db.execute(
|
||||
select(Tag).where(
|
||||
Tag.id == tag_id,
|
||||
Tag.is_deleted == False,
|
||||
)
|
||||
)
|
||||
tag = tag_result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=400, detail=f"Tag {tag_id} not found")
|
||||
|
||||
existing = await db.execute(
|
||||
select(DocumentTag).where(
|
||||
DocumentTag.document_id == document_id,
|
||||
DocumentTag.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
dt = DocumentTag(document_id=document_id, tag_id=tag_id)
|
||||
db.add(dt)
|
||||
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
@router.delete("/api/v1/documents/{document_id}/tags/{tag_id}", status_code=204)
|
||||
async def remove_tag(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
await db.execute(
|
||||
delete(DocumentTag).where(
|
||||
DocumentTag.document_id == document_id,
|
||||
DocumentTag.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return None
|
||||
Reference in New Issue
Block a user