diff --git a/backend/migrations/004_add_books_table.sql b/backend/migrations/004_add_books_table.sql new file mode 100644 index 0000000..7b7165d --- /dev/null +++ b/backend/migrations/004_add_books_table.sql @@ -0,0 +1,50 @@ +-- 서적 테이블 및 관계 추가 +-- 2025-08-22: 서적 그룹화 기능 추가 + +-- 서적 테이블 생성 +CREATE TABLE IF NOT EXISTS books ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(500) NOT NULL, + author VARCHAR(200), + publisher VARCHAR(200), + isbn VARCHAR(20) UNIQUE, + description TEXT, + language VARCHAR(10) DEFAULT 'ko', + total_pages INTEGER DEFAULT 0, + cover_image_path VARCHAR(500), + is_public BOOLEAN DEFAULT true, + tags VARCHAR(1000), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE +); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_books_title ON books(title); +CREATE INDEX IF NOT EXISTS idx_books_author ON books(author); +CREATE INDEX IF NOT EXISTS idx_books_created_at ON books(created_at); + +-- documents 테이블에 book_id 컬럼 추가 +ALTER TABLE documents ADD COLUMN IF NOT EXISTS book_id UUID; + +-- 외래키 제약조건 추가 +ALTER TABLE documents ADD CONSTRAINT IF NOT EXISTS fk_documents_book_id + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE SET NULL; + +-- book_id 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_documents_book_id ON documents(book_id); + +-- 업데이트 트리거 함수 생성 (updated_at 자동 업데이트) +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- books 테이블에 업데이트 트리거 추가 +DROP TRIGGER IF EXISTS update_books_updated_at ON books; +CREATE TRIGGER update_books_updated_at + BEFORE UPDATE ON books + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/src/api/routes/book_categories.py b/backend/src/api/routes/book_categories.py new file mode 100644 index 0000000..32d83a8 --- /dev/null +++ b/backend/src/api/routes/book_categories.py @@ -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 + ) diff --git a/backend/src/api/routes/books.py b/backend/src/api/routes/books.py new file mode 100644 index 0000000..dce1034 --- /dev/null +++ b/backend/src/api/routes/books.py @@ -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 \ No newline at end of file diff --git a/backend/src/api/routes/documents.py b/backend/src/api/routes/documents.py index 5ac2639..5ac9386 100644 --- a/backend/src/api/routes/documents.py +++ b/backend/src/api/routes/documents.py @@ -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() diff --git a/backend/src/api/routes/highlights.py b/backend/src/api/routes/highlights.py index d68c240..68d4ca0 100644 --- a/backend/src/api/routes/highlights.py +++ b/backend/src/api/routes/highlights.py @@ -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) diff --git a/backend/src/main.py b/backend/src/main.py index 4826788..15fed77 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -9,7 +9,7 @@ import uvicorn from .core.config import settings from .core.database import init_db -from .api.routes import auth, users, documents, highlights, notes, bookmarks, search +from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories @asynccontextmanager @@ -47,6 +47,8 @@ app.include_router(users.router, prefix="/api/users", tags=["사용자"]) app.include_router(documents.router, prefix="/api/documents", tags=["문서"]) app.include_router(highlights.router, prefix="/api/highlights", tags=["하이라이트"]) app.include_router(notes.router, prefix="/api/notes", tags=["메모"]) +app.include_router(books.router, prefix="/api/books", tags=["서적"]) +app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"]) app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"]) app.include_router(search.router, prefix="/api/search", tags=["검색"]) diff --git a/backend/src/models/book.py b/backend/src/models/book.py new file mode 100644 index 0000000..cb0c67a --- /dev/null +++ b/backend/src/models/book.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, String, DateTime, Text, Boolean +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + +class Book(Base): + """서적 테이블 (여러 문서를 묶는 단위)""" + __tablename__ = "books" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String(500), nullable=False, index=True) + author = Column(String(255), nullable=True) + description = Column(Text, nullable=True) + language = Column(String(10), default="ko") + is_public = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # 관계 + documents = relationship("Document", back_populates="book", cascade="all, delete-orphan") + categories = relationship("BookCategory", back_populates="book", cascade="all, delete-orphan", order_by="BookCategory.sort_order") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/src/models/book_category.py b/backend/src/models/book_category.py new file mode 100644 index 0000000..834ac4c --- /dev/null +++ b/backend/src/models/book_category.py @@ -0,0 +1,26 @@ +from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + +class BookCategory(Base): + """서적 소분류 테이블 (서적 내 문서 그룹화)""" + __tablename__ = "book_categories" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + book_id = Column(UUID(as_uuid=True), ForeignKey('books.id', ondelete='CASCADE'), nullable=False, index=True) + name = Column(String(200), nullable=False) # 소분류 이름 (예: "Chapter 1", "설계 기준", "계산서") + description = Column(Text, nullable=True) # 소분류 설명 + sort_order = Column(Integer, default=0) # 소분류 정렬 순서 + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # 관계 + book = relationship("Book", back_populates="categories") + documents = relationship("Document", back_populates="category", cascade="all, delete-orphan") + + def __repr__(self): + return f"" diff --git a/backend/src/models/document.py b/backend/src/models/document.py index 68252f3..583edcb 100644 --- a/backend/src/models/document.py +++ b/backend/src/models/document.py @@ -24,7 +24,10 @@ class Document(Base): __tablename__ = "documents" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + book_id = Column(UUID(as_uuid=True), ForeignKey('books.id'), nullable=True, index=True) # 서적 ID + category_id = Column(UUID(as_uuid=True), ForeignKey('book_categories.id'), nullable=True, index=True) # 소분류 ID title = Column(String(500), nullable=False, index=True) + sort_order = Column(Integer, default=0) # 문서 정렬 순서 (소분류 내에서) description = Column(Text, nullable=True) # 파일 정보 @@ -51,6 +54,8 @@ class Document(Base): document_date = Column(DateTime(timezone=True), nullable=True) # 문서 작성일 (사용자 입력) # 관계 + book = relationship("Book", back_populates="documents") # 서적 관계 + category = relationship("BookCategory", back_populates="documents") # 소분류 관계 uploader = relationship("User", backref="uploaded_documents") tags = relationship("Tag", secondary=document_tags, back_populates="documents") highlights = relationship("Highlight", back_populates="document", cascade="all, delete-orphan") diff --git a/backend/src/schemas/book.py b/backend/src/schemas/book.py new file mode 100644 index 0000000..8c3c5f9 --- /dev/null +++ b/backend/src/schemas/book.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from uuid import UUID + +class BookBase(BaseModel): + title: str = Field(..., min_length=1, max_length=500) + author: Optional[str] = Field(None, max_length=255) + description: Optional[str] = None + language: str = Field("ko", max_length=10) + is_public: bool = False + +class CreateBookRequest(BookBase): + pass + +class UpdateBookRequest(BookBase): + pass + +class BookResponse(BookBase): + id: UUID + created_at: datetime + updated_at: Optional[datetime] + document_count: int = 0 # 문서 개수 추가 + + class Config: + from_attributes = True + +class BookSearchResponse(BookResponse): + pass + +class BookSuggestionResponse(BookResponse): + similarity_score: float = Field(..., ge=0.0, le=1.0) \ No newline at end of file diff --git a/backend/src/schemas/book_category.py b/backend/src/schemas/book_category.py new file mode 100644 index 0000000..cdcaf4c --- /dev/null +++ b/backend/src/schemas/book_category.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from uuid import UUID + +class BookCategoryBase(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + description: Optional[str] = None + sort_order: int = Field(0, ge=0) + +class CreateBookCategoryRequest(BookCategoryBase): + book_id: UUID + +class UpdateBookCategoryRequest(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + sort_order: Optional[int] = Field(None, ge=0) + +class BookCategoryResponse(BookCategoryBase): + id: UUID + book_id: UUID + created_at: datetime + updated_at: Optional[datetime] + document_count: int = 0 # 포함된 문서 수 + + class Config: + from_attributes = True + +class UpdateDocumentOrderRequest(BaseModel): + document_orders: List[dict] = Field(..., description="문서 ID와 순서 정보") + # 예: [{"document_id": "uuid", "sort_order": 1}, ...] diff --git a/frontend/hierarchy.html b/frontend/hierarchy.html new file mode 100644 index 0000000..af7c5b4 --- /dev/null +++ b/frontend/hierarchy.html @@ -0,0 +1,677 @@ + + + + + + 문서 관리 시스템 - 계층구조 + + + + + + + +
+
+
+
+

📚 문서 관리 시스템

+
+ 그리드 뷰 + 계층구조 뷰 +
+
+ +
+ +
+ + +
+ + +
+ + +
+ + + +
+
+
+
+ + +
+ + + + +
+ +
+
+
+

+

+
+ +
+ + + +
+
+
+ + +
+
+
+ +

문서를 선택하세요

+

왼쪽 사이드바에서 문서를 클릭하면 내용이 표시됩니다.

+
+
+ +
+
+
+ +
+
+ +

문서를 불러오는 중...

+
+
+
+ + + +
+
+ + +
+
+

로그인

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ + +
+
+

문서 업로드

+
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + +
+ +
+ +
+
+
+
+
+
+ + +
+ + +
+

비슷한 서적이 있습니다:

+ +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+
+ + + + + + + diff --git a/frontend/index.html b/frontend/index.html index 3141943..2144c22 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -12,9 +12,9 @@ -
+
-
+

로그인

@@ -53,11 +53,15 @@
-
+

Document Server

+
+ 그리드 뷰 + 계층구조 뷰 +
@@ -181,7 +185,16 @@
-

+

+ + +
+
+ + + +
+