191 lines
5.8 KiB
Python
191 lines
5.8 KiB
Python
"""
|
|
문서 관리 API — 편집장(editor) 이상 접근.
|
|
POST /api/admin/documents/upload — 문서 업로드
|
|
POST /api/admin/documents/{id}/approve — 문서 승인 (is_active=True)
|
|
GET /api/admin/documents — 문서 목록
|
|
DELETE /api/admin/documents/{id} — 문서 삭제
|
|
"""
|
|
from typing import Optional
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_db
|
|
from app.core.deps import require_editor, require_admin, get_current_admin
|
|
from app.models.admin import AdminUser
|
|
from app.models.knowledge import Document
|
|
from app.services.document_processor import DocumentProcessor
|
|
from app.services.audit import log_action
|
|
|
|
router = APIRouter(prefix="/api/admin/documents", tags=["admin-documents"])
|
|
|
|
|
|
class DocumentOut(BaseModel):
|
|
id: str
|
|
filename: str
|
|
status: str
|
|
is_active: bool
|
|
chunk_count: int
|
|
version: int
|
|
published_at: Optional[datetime] = None
|
|
created_at: Optional[datetime] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
|
async def upload_document(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
published_at: Optional[str] = Form(default=None),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: AdminUser = Depends(require_editor),
|
|
):
|
|
"""문서 업로드 → 파싱·임베딩 → VectorDB 저장. is_active=False (승인 대기)."""
|
|
tenant_id = current_user.tenant_id
|
|
content = await file.read()
|
|
|
|
# 지원 형식 검사
|
|
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
|
|
SUPPORTED = {"txt", "md", "html", "htm", "docx", "pdf"}
|
|
if ext not in SUPPORTED:
|
|
raise HTTPException(status_code=400, detail=f"Unsupported file type: .{ext}")
|
|
|
|
# Document 레코드 생성
|
|
published = None
|
|
if published_at:
|
|
try:
|
|
published = datetime.fromisoformat(published_at)
|
|
except ValueError:
|
|
pass
|
|
|
|
doc = Document(
|
|
tenant_id=tenant_id,
|
|
filename=file.filename,
|
|
source_type="upload",
|
|
is_active=False, # 편집장 승인 전
|
|
status="pending",
|
|
published_at=published,
|
|
approved_by=None,
|
|
)
|
|
db.add(doc)
|
|
await db.flush() # id 확보
|
|
|
|
# 문서 처리
|
|
providers = getattr(request.app.state, "providers", {})
|
|
processor = DocumentProcessor(
|
|
embedding_provider=providers.get("embedding"),
|
|
vectordb_provider=providers.get("vectordb"),
|
|
db=db,
|
|
)
|
|
chunk_count = await processor.process(tenant_id, doc, content)
|
|
|
|
# 감사 로그
|
|
await log_action(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
actor_id=current_user.id,
|
|
actor_type="admin_user",
|
|
action="doc.upload",
|
|
target_type="document",
|
|
target_id=doc.id,
|
|
diff={"filename": file.filename, "chunk_count": chunk_count},
|
|
)
|
|
|
|
return {"id": doc.id, "filename": doc.filename, "status": doc.status, "chunk_count": chunk_count}
|
|
|
|
|
|
@router.post("/{doc_id}/approve")
|
|
async def approve_document(
|
|
doc_id: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: AdminUser = Depends(require_editor),
|
|
):
|
|
"""문서 승인 → is_active=True."""
|
|
tenant_id = current_user.tenant_id
|
|
result = await db.execute(
|
|
select(Document).where(Document.id == doc_id, Document.tenant_id == tenant_id)
|
|
)
|
|
doc = result.scalar_one_or_none()
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
if doc.status not in ("processed", "embedding_unavailable"):
|
|
raise HTTPException(status_code=400, detail=f"Cannot approve document with status: {doc.status}")
|
|
|
|
doc.is_active = True
|
|
doc.approved_by = current_user.id
|
|
doc.approved_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
await log_action(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
actor_id=current_user.id,
|
|
actor_type="admin_user",
|
|
action="doc.approve",
|
|
target_type="document",
|
|
target_id=doc.id,
|
|
)
|
|
|
|
return {"id": doc.id, "is_active": True}
|
|
|
|
|
|
@router.get("", response_model=list[DocumentOut])
|
|
async def list_documents(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: AdminUser = Depends(get_current_admin),
|
|
):
|
|
"""문서 목록 조회."""
|
|
result = await db.execute(
|
|
select(Document)
|
|
.where(Document.tenant_id == current_user.tenant_id)
|
|
.order_by(Document.created_at.desc())
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.delete("/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_document(
|
|
doc_id: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: AdminUser = Depends(require_editor),
|
|
):
|
|
"""문서 삭제 (DB + VectorDB)."""
|
|
tenant_id = current_user.tenant_id
|
|
result = await db.execute(
|
|
select(Document).where(Document.id == doc_id, Document.tenant_id == tenant_id)
|
|
)
|
|
doc = result.scalar_one_or_none()
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
|
|
# VectorDB 청크 삭제
|
|
providers = getattr(request.app.state, "providers", {})
|
|
vectordb = providers.get("vectordb")
|
|
if vectordb:
|
|
processor = DocumentProcessor(
|
|
embedding_provider=providers.get("embedding"),
|
|
vectordb_provider=vectordb,
|
|
db=db,
|
|
)
|
|
await processor.delete(tenant_id, doc_id)
|
|
|
|
await db.delete(doc)
|
|
await db.commit()
|
|
|
|
await log_action(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
actor_id=current_user.id,
|
|
actor_type="admin_user",
|
|
action="doc.delete",
|
|
target_type="document",
|
|
target_id=doc_id,
|
|
)
|