Files
document-server/backend/src/api/routes/documents.py
Hyungi Ahn 3036b8f0fb 🎉 Initial commit: Document Server MVP
 Features implemented:
- FastAPI backend with JWT authentication
- PostgreSQL database with async SQLAlchemy
- HTML document viewer with smart highlighting
- Note system connected to highlights (1:1 relationship)
- Bookmark system for quick navigation
- Integrated search (documents + notes)
- Tag system for document organization
- Docker containerization with Nginx

🔧 Technical stack:
- Backend: FastAPI + PostgreSQL + Redis
- Frontend: Alpine.js + Tailwind CSS
- Authentication: JWT tokens
- File handling: HTML + PDF support
- Search: Full-text search with relevance scoring

📋 Core functionality:
- Text selection → Highlight creation
- Highlight → Note attachment
- Note management with search/filtering
- Bookmark creation at scroll positions
- Document upload with metadata
- User management (admin creates accounts)
2025-08-21 16:09:17 +09:00

360 lines
11 KiB
Python

"""
문서 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
import os
import uuid
import aiofiles
from pathlib import Path
from src.core.database import get_db
from src.core.config import settings
from src.models.user import User
from src.models.document import Document, Tag
from src.api.dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel
from datetime import datetime
class DocumentResponse(BaseModel):
"""문서 응답"""
id: str
title: str
description: Optional[str]
html_path: str
pdf_path: Optional[str]
thumbnail_path: Optional[str]
file_size: Optional[int]
page_count: Optional[int]
language: str
is_public: bool
is_processed: bool
created_at: datetime
updated_at: Optional[datetime]
document_date: Optional[datetime]
uploader_name: Optional[str]
tags: List[str] = []
class Config:
from_attributes = True
class TagResponse(BaseModel):
"""태그 응답"""
id: str
name: str
color: str
description: Optional[str]
document_count: int = 0
class Config:
from_attributes = True
class CreateTagRequest(BaseModel):
"""태그 생성 요청"""
name: str
color: str = "#3B82F6"
description: Optional[str] = None
router = APIRouter()
@router.get("/", response_model=List[DocumentResponse])
async def list_documents(
skip: int = 0,
limit: int = 50,
tag: Optional[str] = None,
search: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 목록 조회"""
query = select(Document).options(
selectinload(Document.uploader),
selectinload(Document.tags)
)
# 권한 필터링 (관리자가 아니면 공개 문서 + 자신이 업로드한 문서만)
if not current_user.is_admin:
query = query.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
# 태그 필터링
if tag:
query = query.join(Document.tags).where(Tag.name == tag)
# 검색 필터링
if search:
query = query.where(
or_(
Document.title.ilike(f"%{search}%"),
Document.description.ilike(f"%{search}%")
)
)
query = query.order_by(Document.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
documents = result.scalars().all()
# 응답 데이터 변환
response_data = []
for doc in documents:
doc_data = DocumentResponse.from_orm(doc)
doc_data.uploader_name = doc.uploader.full_name or doc.uploader.email
doc_data.tags = [tag.name for tag in doc.tags]
response_data.append(doc_data)
return response_data
@router.post("/", response_model=DocumentResponse)
async def upload_document(
title: str = Form(...),
description: Optional[str] = Form(None),
document_date: Optional[str] = Form(None),
is_public: bool = Form(False),
tags: Optional[str] = Form(None), # 쉼표로 구분된 태그
html_file: UploadFile = File(...),
pdf_file: Optional[UploadFile] = File(None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 업로드"""
# 파일 확장자 확인
if not html_file.filename.lower().endswith(('.html', '.htm')):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only HTML files are allowed for the main document"
)
if pdf_file and not pdf_file.filename.lower().endswith('.pdf'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only PDF files are allowed for the original document"
)
# 고유 파일명 생성
doc_id = str(uuid.uuid4())
html_filename = f"{doc_id}.html"
pdf_filename = f"{doc_id}.pdf" if pdf_file else None
# 파일 저장 경로
html_path = os.path.join(settings.UPLOAD_DIR, "documents", html_filename)
pdf_path = os.path.join(settings.UPLOAD_DIR, "documents", pdf_filename) if pdf_file else None
try:
# HTML 파일 저장
async with aiofiles.open(html_path, 'wb') as f:
content = await html_file.read()
await f.write(content)
# PDF 파일 저장 (있는 경우)
if pdf_file and pdf_path:
async with aiofiles.open(pdf_path, 'wb') as f:
content = await pdf_file.read()
await f.write(content)
# 문서 메타데이터 생성
document = Document(
id=doc_id,
title=title,
description=description,
html_path=html_path,
pdf_path=pdf_path,
file_size=len(await html_file.read()) if html_file else None,
uploaded_by=current_user.id,
original_filename=html_file.filename,
is_public=is_public,
document_date=datetime.fromisoformat(document_date) if document_date else None
)
db.add(document)
await db.flush() # ID 생성을 위해
# 태그 처리
if tags:
tag_names = [tag.strip() for tag in tags.split(',') if tag.strip()]
for tag_name in tag_names:
# 기존 태그 찾기 또는 생성
result = await db.execute(select(Tag).where(Tag.name == tag_name))
tag = result.scalar_one_or_none()
if not tag:
tag = Tag(
name=tag_name,
created_by=current_user.id
)
db.add(tag)
await db.flush()
document.tags.append(tag)
await db.commit()
await db.refresh(document)
# 응답 데이터 생성
response_data = DocumentResponse.from_orm(document)
response_data.uploader_name = current_user.full_name or current_user.email
response_data.tags = [tag.name for tag in document.tags]
return response_data
except Exception as e:
# 파일 정리
if os.path.exists(html_path):
os.remove(html_path)
if pdf_path and os.path.exists(pdf_path):
os.remove(pdf_path)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to upload document: {str(e)}"
)
@router.get("/{document_id}", response_model=DocumentResponse)
async def get_document(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 상세 조회"""
result = await db.execute(
select(Document)
.options(selectinload(Document.uploader), selectinload(Document.tags))
.where(Document.id == document_id)
)
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
response_data = DocumentResponse.from_orm(document)
response_data.uploader_name = document.uploader.full_name or document.uploader.email
response_data.tags = [tag.name for tag in document.tags]
return response_data
@router.delete("/{document_id}")
async def delete_document(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 삭제"""
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인 (업로더 또는 관리자만)
if document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 파일 삭제
if document.html_path and os.path.exists(document.html_path):
os.remove(document.html_path)
if document.pdf_path and os.path.exists(document.pdf_path):
os.remove(document.pdf_path)
if document.thumbnail_path and os.path.exists(document.thumbnail_path):
os.remove(document.thumbnail_path)
# 데이터베이스에서 삭제
await db.execute(delete(Document).where(Document.id == document_id))
await db.commit()
return {"message": "Document deleted successfully"}
@router.get("/tags/", response_model=List[TagResponse])
async def list_tags(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""태그 목록 조회"""
result = await db.execute(select(Tag).order_by(Tag.name))
tags = result.scalars().all()
# 각 태그의 문서 수 계산
response_data = []
for tag in tags:
tag_data = TagResponse.from_orm(tag)
# 문서 수 계산 (권한 고려)
doc_query = select(Document).join(Document.tags).where(Tag.id == tag.id)
if not current_user.is_admin:
doc_query = doc_query.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
doc_result = await db.execute(doc_query)
tag_data.document_count = len(doc_result.scalars().all())
response_data.append(tag_data)
return response_data
@router.post("/tags/", response_model=TagResponse)
async def create_tag(
tag_data: CreateTagRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""태그 생성"""
# 중복 확인
result = await db.execute(select(Tag).where(Tag.name == tag_data.name))
existing_tag = result.scalar_one_or_none()
if existing_tag:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tag already exists"
)
# 태그 생성
tag = Tag(
name=tag_data.name,
color=tag_data.color,
description=tag_data.description,
created_by=current_user.id
)
db.add(tag)
await db.commit()
await db.refresh(tag)
response_data = TagResponse.from_orm(tag)
response_data.document_count = 0
return response_data