feat: 계층구조 뷰 및 완전한 하이라이트/메모 시스템 구현
주요 기능: - 📚 Book 및 BookCategory 모델 추가 (서적 그룹화) - 🏗️ 계층구조 뷰 (Book > Category > Document) 구현 - 🎨 완전한 하이라이트 시스템 (생성, 표시, 삭제) - 📝 통합 메모 관리 (추가, 수정, 삭제) - 🔄 그리드 뷰와 계층구조 뷰 간 완전 동기화 - 🛡️ 관리자 전용 문서 삭제 기능 - 🔧 모든 CORS 및 500 오류 해결 기술적 개선: - API 베이스 URL을 Nginx 프록시로 변경 (/api) - 외래키 제약 조건 해결 (삭제 순서 최적화) - SQLAlchemy 관계 로딩 최적화 (selectinload) - 프론트엔드 캐시 무효화 시스템 - Alpine.js 컴포넌트 구조 개선 UI/UX: - 계층구조 네비게이션 (사이드바 + 트리 구조) - 하이라이트 모드 토글 스위치 - 완전한 툴팁 기반 메모 관리 인터페이스 - 반응형 하이라이트 메뉴 (색상 선택) - 스마트 툴팁 위치 조정 (화면 경계 고려)
This commit is contained in:
155
backend/src/api/routes/book_categories.py
Normal file
155
backend/src/api/routes/book_categories.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from ...core.database import get_db
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...models.user import User
|
||||
from ...models.book import Book
|
||||
from ...models.book_category import BookCategory
|
||||
from ...models.document import Document
|
||||
from ...schemas.book_category import (
|
||||
CreateBookCategoryRequest,
|
||||
UpdateBookCategoryRequest,
|
||||
BookCategoryResponse,
|
||||
UpdateDocumentOrderRequest
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=BookCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_book_category(
|
||||
category_data: CreateBookCategoryRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새로운 서적 소분류 생성"""
|
||||
# 서적 존재 확인
|
||||
book_result = await db.execute(select(Book).where(Book.id == category_data.book_id))
|
||||
book = book_result.scalar_one_or_none()
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# 권한 확인 (관리자만)
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can create categories")
|
||||
|
||||
new_category = BookCategory(**category_data.model_dump())
|
||||
db.add(new_category)
|
||||
await db.commit()
|
||||
await db.refresh(new_category)
|
||||
|
||||
return await _get_category_response(db, new_category)
|
||||
|
||||
@router.get("/book/{book_id}", response_model=List[BookCategoryResponse])
|
||||
async def get_book_categories(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 서적의 소분류 목록 조회"""
|
||||
result = await db.execute(
|
||||
select(BookCategory)
|
||||
.where(BookCategory.book_id == book_id)
|
||||
.order_by(BookCategory.sort_order, BookCategory.name)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
|
||||
response_categories = []
|
||||
for category in categories:
|
||||
response_categories.append(await _get_category_response(db, category))
|
||||
return response_categories
|
||||
|
||||
@router.put("/{category_id}", response_model=BookCategoryResponse)
|
||||
async def update_book_category(
|
||||
category_id: UUID,
|
||||
category_data: UpdateBookCategoryRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 소분류 수정"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update categories")
|
||||
|
||||
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
|
||||
for field, value in category_data.model_dump(exclude_unset=True).items():
|
||||
setattr(category, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(category)
|
||||
return await _get_category_response(db, category)
|
||||
|
||||
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_book_category(
|
||||
category_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 소분류 삭제 (포함된 문서들은 미분류로 이동)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete categories")
|
||||
|
||||
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
|
||||
# 포함된 문서들을 미분류로 이동 (category_id를 NULL로 설정)
|
||||
await db.execute(
|
||||
update(Document)
|
||||
.where(Document.category_id == category_id)
|
||||
.values(category_id=None)
|
||||
)
|
||||
|
||||
await db.delete(category)
|
||||
await db.commit()
|
||||
return {"message": "Category deleted successfully"}
|
||||
|
||||
@router.put("/documents/reorder", status_code=status.HTTP_200_OK)
|
||||
async def update_document_order(
|
||||
order_data: UpdateDocumentOrderRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 순서 변경"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can reorder documents")
|
||||
|
||||
# 문서 순서 업데이트
|
||||
for item in order_data.document_orders:
|
||||
document_id = item.get("document_id")
|
||||
sort_order = item.get("sort_order", 0)
|
||||
|
||||
await db.execute(
|
||||
update(Document)
|
||||
.where(Document.id == document_id)
|
||||
.values(sort_order=sort_order)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Document order updated successfully"}
|
||||
|
||||
# Helper function
|
||||
async def _get_category_response(db: AsyncSession, category: BookCategory) -> BookCategoryResponse:
|
||||
"""BookCategory를 BookCategoryResponse로 변환"""
|
||||
document_count_result = await db.execute(
|
||||
select(func.count(Document.id)).where(Document.category_id == category.id)
|
||||
)
|
||||
document_count = document_count_result.scalar_one()
|
||||
|
||||
return BookCategoryResponse(
|
||||
id=category.id,
|
||||
book_id=category.book_id,
|
||||
name=category.name,
|
||||
description=category.description,
|
||||
sort_order=category.sort_order,
|
||||
created_at=category.created_at,
|
||||
updated_at=category.updated_at,
|
||||
document_count=document_count
|
||||
)
|
||||
230
backend/src/api/routes/books.py
Normal file
230
backend/src/api/routes/books.py
Normal file
@@ -0,0 +1,230 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import difflib # For similarity suggestions
|
||||
|
||||
from ...core.database import get_db
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...models.user import User
|
||||
from ...models.book import Book
|
||||
from ...models.document import Document
|
||||
from ...schemas.book import CreateBookRequest, UpdateBookRequest, BookResponse, BookSearchResponse, BookSuggestionResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Helper to convert Book ORM object to BookResponse
|
||||
async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse:
|
||||
document_count_result = await db.execute(
|
||||
select(func.count(Document.id)).where(Document.book_id == book.id)
|
||||
)
|
||||
document_count = document_count_result.scalar_one()
|
||||
return BookResponse(
|
||||
id=book.id,
|
||||
title=book.title,
|
||||
author=book.author,
|
||||
description=book.description,
|
||||
language=book.language,
|
||||
is_public=book.is_public,
|
||||
created_at=book.created_at,
|
||||
updated_at=book.updated_at,
|
||||
document_count=document_count
|
||||
)
|
||||
|
||||
@router.post("/", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_book(
|
||||
book_data: CreateBookRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새로운 서적 생성"""
|
||||
# Check if a book with the same title and author already exists for the user
|
||||
existing_book_query = select(Book).where(Book.title == book_data.title)
|
||||
if book_data.author:
|
||||
existing_book_query = existing_book_query.where(Book.author == book_data.author)
|
||||
|
||||
existing_book = await db.execute(existing_book_query)
|
||||
if existing_book.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A book with this title and author already exists."
|
||||
)
|
||||
|
||||
new_book = Book(**book_data.model_dump())
|
||||
db.add(new_book)
|
||||
await db.commit()
|
||||
await db.refresh(new_book)
|
||||
return await _get_book_response(db, new_book)
|
||||
|
||||
@router.get("/", response_model=List[BookResponse])
|
||||
async def get_books(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = Query(None, description="Search by book title or author"),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""모든 서적 목록 조회"""
|
||||
query = select(Book)
|
||||
if search:
|
||||
query = query.where(
|
||||
or_(
|
||||
Book.title.ilike(f"%{search}%"),
|
||||
Book.author.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
# Only show public books or books owned by the current user/admin
|
||||
if not current_user.is_admin:
|
||||
query = query.where(Book.is_public == True) # For simplicity, assuming all books are public for now or user can only see public ones.
|
||||
# In a real app, you'd link books to users.
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Book.title)
|
||||
result = await db.execute(query)
|
||||
books = result.scalars().all()
|
||||
|
||||
response_books = []
|
||||
for book in books:
|
||||
response_books.append(await _get_book_response(db, book))
|
||||
return response_books
|
||||
|
||||
@router.get("/{book_id}", response_model=BookResponse)
|
||||
async def get_book(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 서적 상세 정보 조회"""
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# Access control (simplified)
|
||||
if not book.is_public and not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions to access this book")
|
||||
|
||||
return await _get_book_response(db, book)
|
||||
|
||||
@router.put("/{book_id}", response_model=BookResponse)
|
||||
async def update_book(
|
||||
book_id: UUID,
|
||||
book_data: UpdateBookRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 정보 업데이트"""
|
||||
if not current_user.is_admin: # Only admin can update books for now
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update books")
|
||||
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
for field, value in book_data.model_dump(exclude_unset=True).items():
|
||||
setattr(book, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(book)
|
||||
return await _get_book_response(db, book)
|
||||
|
||||
@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_book(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 삭제"""
|
||||
if not current_user.is_admin: # Only admin can delete books for now
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete books")
|
||||
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# Disassociate documents from this book before deleting
|
||||
await db.execute(
|
||||
select(Document).where(Document.book_id == book_id)
|
||||
)
|
||||
documents_to_update = (await db.execute(select(Document).where(Document.book_id == book_id))).scalars().all()
|
||||
for doc in documents_to_update:
|
||||
doc.book_id = None
|
||||
|
||||
await db.delete(book)
|
||||
await db.commit()
|
||||
return {"message": "Book deleted successfully"}
|
||||
|
||||
@router.get("/search/", response_model=List[BookSearchResponse])
|
||||
async def search_books(
|
||||
q: str = Query(..., min_length=1, description="Search query for book title or author"),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 검색 (제목 또는 저자)"""
|
||||
query = select(Book).where(
|
||||
or_(
|
||||
Book.title.ilike(f"%{q}%"),
|
||||
Book.author.ilike(f"%{q}%")
|
||||
)
|
||||
)
|
||||
if not current_user.is_admin:
|
||||
query = query.where(Book.is_public == True)
|
||||
|
||||
result = await db.execute(query.limit(limit))
|
||||
books = result.scalars().all()
|
||||
|
||||
response_books = []
|
||||
for book in books:
|
||||
response_books.append(await _get_book_response(db, book))
|
||||
return response_books
|
||||
|
||||
@router.get("/suggestions/", response_model=List[BookSuggestionResponse])
|
||||
async def get_book_suggestions(
|
||||
title: str = Query(..., min_length=1, description="Book title for suggestions"),
|
||||
limit: int = Query(5, ge=1, le=10),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""제목 유사도 기반 서적 추천"""
|
||||
all_books_query = select(Book)
|
||||
if not current_user.is_admin:
|
||||
all_books_query = all_books_query.where(Book.is_public == True)
|
||||
|
||||
all_books_result = await db.execute(all_books_query)
|
||||
all_books = all_books_result.scalars().all()
|
||||
|
||||
suggestions = []
|
||||
for book in all_books:
|
||||
# Calculate similarity score using difflib
|
||||
score = difflib.SequenceMatcher(None, title.lower(), book.title.lower()).ratio()
|
||||
if score > 0.1: # Only consider if there's some similarity
|
||||
suggestions.append({
|
||||
"book": book,
|
||||
"similarity_score": score
|
||||
})
|
||||
|
||||
# Sort by similarity score in descending order
|
||||
suggestions.sort(key=lambda x: x["similarity_score"], reverse=True)
|
||||
|
||||
response_suggestions = []
|
||||
for s in suggestions[:limit]:
|
||||
book_response = await _get_book_response(db, s["book"])
|
||||
response_suggestions.append(BookSuggestionResponse(
|
||||
**book_response.model_dump(),
|
||||
similarity_score=s["similarity_score"]
|
||||
))
|
||||
return response_suggestions
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,6 +16,7 @@ from ...core.database import get_db
|
||||
from ...core.config import settings
|
||||
from ...models.user import User
|
||||
from ...models.document import Document, Tag
|
||||
from ...models.book import Book
|
||||
from ..dependencies import get_current_active_user, get_current_admin_user
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
@@ -38,6 +40,16 @@ class DocumentResponse(BaseModel):
|
||||
document_date: Optional[datetime]
|
||||
uploader_name: Optional[str]
|
||||
tags: List[str] = []
|
||||
|
||||
# 서적 정보
|
||||
book_id: Optional[str] = None
|
||||
book_title: Optional[str] = None
|
||||
book_author: Optional[str] = None
|
||||
|
||||
# 소분류 정보
|
||||
category_id: Optional[str] = None
|
||||
category_name: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -77,7 +89,9 @@ async def list_documents(
|
||||
"""문서 목록 조회"""
|
||||
query = select(Document).options(
|
||||
selectinload(Document.uploader),
|
||||
selectinload(Document.tags)
|
||||
selectinload(Document.tags),
|
||||
selectinload(Document.book), # 서적 정보 추가
|
||||
selectinload(Document.category) # 소분류 정보 추가
|
||||
)
|
||||
|
||||
# 권한 필터링 (관리자가 아니면 공개 문서 + 자신이 업로드한 문서만)
|
||||
@@ -126,13 +140,108 @@ async def list_documents(
|
||||
updated_at=doc.updated_at,
|
||||
document_date=doc.document_date,
|
||||
uploader_name=doc.uploader.full_name or doc.uploader.email,
|
||||
tags=[tag.name for tag in doc.tags]
|
||||
tags=[tag.name for tag in doc.tags],
|
||||
# 서적 정보 추가
|
||||
book_id=str(doc.book.id) if doc.book else None,
|
||||
book_title=doc.book.title if doc.book else None,
|
||||
book_author=doc.book.author if doc.book else None,
|
||||
# 소분류 정보 추가
|
||||
category_id=str(doc.category.id) if doc.category else None,
|
||||
category_name=doc.category.name if doc.category else None,
|
||||
sort_order=doc.sort_order
|
||||
)
|
||||
response_data.append(doc_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/hierarchy/structured", response_model=dict)
|
||||
async def get_documents_by_hierarchy(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""계층구조별 문서 조회 (서적 > 소분류 > 문서)"""
|
||||
# 모든 문서 조회 (기존 로직 재사용)
|
||||
query = select(Document).options(
|
||||
selectinload(Document.uploader),
|
||||
selectinload(Document.tags),
|
||||
selectinload(Document.book),
|
||||
selectinload(Document.category)
|
||||
)
|
||||
|
||||
# 권한 필터링
|
||||
if not current_user.is_admin:
|
||||
query = query.where(
|
||||
or_(
|
||||
Document.is_public == True,
|
||||
Document.uploaded_by == current_user.id
|
||||
)
|
||||
)
|
||||
|
||||
# 정렬: 서적별 > 소분류별 > 문서 순서별
|
||||
query = query.order_by(
|
||||
Document.book_id.nulls_last(), # 서적 있는 것 먼저
|
||||
Document.category_id.nulls_last(), # 소분류 있는 것 먼저
|
||||
Document.sort_order,
|
||||
Document.created_at.desc()
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
documents = result.scalars().all()
|
||||
|
||||
# 계층구조로 그룹화
|
||||
hierarchy = {
|
||||
"books": {}, # 서적별 그룹
|
||||
"uncategorized": [] # 미분류 문서들
|
||||
}
|
||||
|
||||
for doc in documents:
|
||||
doc_data = {
|
||||
"id": str(doc.id),
|
||||
"title": doc.title,
|
||||
"description": doc.description,
|
||||
"created_at": doc.created_at.isoformat(),
|
||||
"uploader_name": doc.uploader.full_name or doc.uploader.email,
|
||||
"tags": [tag.name for tag in doc.tags],
|
||||
"sort_order": doc.sort_order,
|
||||
"book_id": str(doc.book.id) if doc.book else None,
|
||||
"book_title": doc.book.title if doc.book else None,
|
||||
"category_id": str(doc.category.id) if doc.category else None,
|
||||
"category_name": doc.category.name if doc.category else None
|
||||
}
|
||||
|
||||
if doc.book:
|
||||
# 서적이 있는 문서
|
||||
book_id = str(doc.book.id)
|
||||
if book_id not in hierarchy["books"]:
|
||||
hierarchy["books"][book_id] = {
|
||||
"id": book_id,
|
||||
"title": doc.book.title,
|
||||
"author": doc.book.author,
|
||||
"categories": {},
|
||||
"uncategorized_documents": []
|
||||
}
|
||||
|
||||
if doc.category:
|
||||
# 소분류가 있는 문서
|
||||
category_id = str(doc.category.id)
|
||||
if category_id not in hierarchy["books"][book_id]["categories"]:
|
||||
hierarchy["books"][book_id]["categories"][category_id] = {
|
||||
"id": category_id,
|
||||
"name": doc.category.name,
|
||||
"documents": []
|
||||
}
|
||||
hierarchy["books"][book_id]["categories"][category_id]["documents"].append(doc_data)
|
||||
else:
|
||||
# 서적은 있지만 소분류가 없는 문서
|
||||
hierarchy["books"][book_id]["uncategorized_documents"].append(doc_data)
|
||||
else:
|
||||
# 서적이 없는 미분류 문서
|
||||
hierarchy["uncategorized"].append(doc_data)
|
||||
|
||||
return hierarchy
|
||||
|
||||
|
||||
@router.post("/", response_model=DocumentResponse)
|
||||
async def upload_document(
|
||||
title: str = Form(...),
|
||||
@@ -141,6 +250,7 @@ async def upload_document(
|
||||
language: Optional[str] = Form("ko"),
|
||||
is_public: bool = Form(False),
|
||||
tags: Optional[List[str]] = Form(None), # 태그 리스트
|
||||
book_id: Optional[str] = Form(None), # 서적 ID 추가
|
||||
html_file: UploadFile = File(...),
|
||||
pdf_file: Optional[UploadFile] = File(None),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
@@ -181,6 +291,21 @@ async def upload_document(
|
||||
content = await pdf_file.read()
|
||||
await f.write(content)
|
||||
|
||||
# 서적 ID 검증 (있는 경우)
|
||||
validated_book_id = None
|
||||
if book_id:
|
||||
try:
|
||||
# UUID 형식 검증 및 서적 존재 확인
|
||||
from uuid import UUID
|
||||
book_uuid = UUID(book_id)
|
||||
book_result = await db.execute(select(Book).where(Book.id == book_uuid))
|
||||
book = book_result.scalar_one_or_none()
|
||||
if book:
|
||||
validated_book_id = book_uuid
|
||||
except (ValueError, Exception):
|
||||
# 잘못된 UUID 형식이거나 서적이 없으면 무시
|
||||
pass
|
||||
|
||||
# 문서 메타데이터 생성
|
||||
document = Document(
|
||||
id=doc_id,
|
||||
@@ -193,7 +318,8 @@ async def upload_document(
|
||||
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
|
||||
document_date=datetime.fromisoformat(document_date) if document_date else None,
|
||||
book_id=validated_book_id # 서적 ID 추가
|
||||
)
|
||||
|
||||
db.add(document)
|
||||
@@ -307,6 +433,48 @@ async def get_document(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}/content")
|
||||
async def get_document_content(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 HTML 콘텐츠 조회"""
|
||||
try:
|
||||
doc_uuid = UUID(document_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid document ID format")
|
||||
|
||||
# 문서 조회
|
||||
query = select(Document).where(Document.id == doc_uuid)
|
||||
result = await db.execute(query)
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# 권한 확인
|
||||
if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# HTML 파일 읽기
|
||||
import os
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
# html_path는 이미 전체 경로를 포함하고 있음
|
||||
file_path = document.html_path
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="Document file not found")
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
return HTMLResponse(content=content)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/{document_id}")
|
||||
async def delete_document(
|
||||
document_id: str,
|
||||
@@ -323,11 +491,11 @@ async def delete_document(
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 권한 확인 (업로더 또는 관리자만)
|
||||
if document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
# 권한 확인 (관리자만)
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
detail="Only administrators can delete documents"
|
||||
)
|
||||
|
||||
# 파일 삭제
|
||||
@@ -338,7 +506,23 @@ async def delete_document(
|
||||
if document.thumbnail_path and os.path.exists(document.thumbnail_path):
|
||||
os.remove(document.thumbnail_path)
|
||||
|
||||
# 데이터베이스에서 삭제
|
||||
# 관련 데이터 먼저 삭제 (외래키 제약 조건 해결)
|
||||
from ...models.highlight import Highlight
|
||||
from ...models.note import Note
|
||||
from ...models.bookmark import Bookmark
|
||||
|
||||
# 메모 먼저 삭제 (하이라이트를 참조하므로)
|
||||
await db.execute(delete(Note).where(Note.document_id == document_id))
|
||||
|
||||
# 북마크 삭제
|
||||
await db.execute(delete(Bookmark).where(Bookmark.document_id == document_id))
|
||||
|
||||
# 마지막으로 하이라이트 삭제
|
||||
await db.execute(delete(Highlight).where(Highlight.document_id == document_id))
|
||||
|
||||
# 문서-태그 관계 삭제 (Document.tags 관계를 통해 자동 처리됨)
|
||||
|
||||
# 마지막으로 문서 삭제
|
||||
await db.execute(delete(Document).where(Document.id == document_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class UpdateHighlightRequest(BaseModel):
|
||||
"""하이라이트 업데이트 요청"""
|
||||
highlight_color: Optional[str] = None
|
||||
highlight_type: Optional[str] = None
|
||||
note: Optional[str] = None # 메모 업데이트 지원
|
||||
|
||||
|
||||
class HighlightResponse(BaseModel):
|
||||
@@ -155,63 +156,60 @@ async def get_document_highlights(
|
||||
try:
|
||||
print(f"DEBUG: Getting highlights for document {document_id}, user {current_user.id}")
|
||||
|
||||
# 임시로 빈 배열 반환 (테스트용)
|
||||
return []
|
||||
# 문서 존재 및 권한 확인
|
||||
result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
# 원래 코드는 주석 처리
|
||||
# # 문서 존재 및 권한 확인
|
||||
# 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 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 to access this document"
|
||||
# )
|
||||
#
|
||||
# # 사용자의 하이라이트만 조회 (notes 로딩 제거)
|
||||
# result = await db.execute(
|
||||
# select(Highlight)
|
||||
# .where(
|
||||
# and_(
|
||||
# Highlight.document_id == document_id,
|
||||
# Highlight.user_id == current_user.id
|
||||
# )
|
||||
# )
|
||||
# .order_by(Highlight.start_offset)
|
||||
# )
|
||||
# highlights = result.scalars().all()
|
||||
#
|
||||
# # 응답 데이터 변환
|
||||
# response_data = []
|
||||
# for highlight in highlights:
|
||||
# highlight_data = HighlightResponse(
|
||||
# id=str(highlight.id),
|
||||
# user_id=str(highlight.user_id),
|
||||
# document_id=str(highlight.document_id),
|
||||
# start_offset=highlight.start_offset,
|
||||
# end_offset=highlight.end_offset,
|
||||
# selected_text=highlight.selected_text,
|
||||
# element_selector=highlight.element_selector,
|
||||
# start_container_xpath=highlight.start_container_xpath,
|
||||
# end_container_xpath=highlight.end_container_xpath,
|
||||
# highlight_color=highlight.highlight_color,
|
||||
# highlight_type=highlight.highlight_type,
|
||||
# created_at=highlight.created_at,
|
||||
# updated_at=highlight.updated_at,
|
||||
# note=None
|
||||
# )
|
||||
# # 메모는 별도 API에서 조회하므로 여기서는 처리하지 않음
|
||||
# response_data.append(highlight_data)
|
||||
#
|
||||
# return response_data
|
||||
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 to access this document"
|
||||
)
|
||||
|
||||
# 사용자의 하이라이트만 조회
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.where(
|
||||
and_(
|
||||
Highlight.document_id == document_id,
|
||||
Highlight.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
.order_by(Highlight.start_offset)
|
||||
)
|
||||
highlights = result.scalars().all()
|
||||
|
||||
print(f"DEBUG: Found {len(highlights)} highlights for user {current_user.id}")
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for highlight in highlights:
|
||||
highlight_data = HighlightResponse(
|
||||
id=str(highlight.id),
|
||||
user_id=str(highlight.user_id),
|
||||
document_id=str(highlight.document_id),
|
||||
start_offset=highlight.start_offset,
|
||||
end_offset=highlight.end_offset,
|
||||
selected_text=highlight.selected_text,
|
||||
element_selector=highlight.element_selector,
|
||||
start_container_xpath=highlight.start_container_xpath,
|
||||
end_container_xpath=highlight.end_container_xpath,
|
||||
highlight_color=highlight.highlight_color,
|
||||
highlight_type=highlight.highlight_type,
|
||||
created_at=highlight.created_at,
|
||||
updated_at=highlight.updated_at,
|
||||
note=None
|
||||
)
|
||||
response_data.append(highlight_data)
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_document_highlights: {e}")
|
||||
@@ -288,7 +286,7 @@ async def update_highlight(
|
||||
"""하이라이트 업데이트"""
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.options(selectinload(Highlight.user))
|
||||
.options(selectinload(Highlight.user), selectinload(Highlight.notes))
|
||||
.where(Highlight.id == highlight_id)
|
||||
)
|
||||
highlight = result.scalar_one_or_none()
|
||||
@@ -312,6 +310,23 @@ async def update_highlight(
|
||||
if highlight_data.highlight_type:
|
||||
highlight.highlight_type = highlight_data.highlight_type
|
||||
|
||||
# 메모 업데이트 처리
|
||||
if highlight_data.note is not None:
|
||||
if highlight.notes:
|
||||
# 기존 메모 업데이트
|
||||
highlight.notes.content = highlight_data.note
|
||||
highlight.notes.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# 새 메모 생성
|
||||
new_note = Note(
|
||||
user_id=current_user.id,
|
||||
document_id=highlight.document_id,
|
||||
highlight_id=highlight.id,
|
||||
content=highlight_data.note,
|
||||
tags=""
|
||||
)
|
||||
db.add(new_note)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(highlight)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user