From 46546da55f8d088df1ccf595c6f597d64a4268c6 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sat, 23 Aug 2025 14:31:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=84=EC=B8=B5=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=8F=20=EC=99=84=EC=A0=84=ED=95=9C=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8/=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ์ฃผ์š” ๊ธฐ๋Šฅ: - ๐Ÿ“š Book ๋ฐ BookCategory ๋ชจ๋ธ ์ถ”๊ฐ€ (์„œ์  ๊ทธ๋ฃนํ™”) - ๐Ÿ—๏ธ ๊ณ„์ธต๊ตฌ์กฐ ๋ทฐ (Book > Category > Document) ๊ตฌํ˜„ - ๐ŸŽจ ์™„์ „ํ•œ ํ•˜์ด๋ผ์ดํŠธ ์‹œ์Šคํ…œ (์ƒ์„ฑ, ํ‘œ์‹œ, ์‚ญ์ œ) - ๐Ÿ“ ํ†ตํ•ฉ ๋ฉ”๋ชจ ๊ด€๋ฆฌ (์ถ”๊ฐ€, ์ˆ˜์ •, ์‚ญ์ œ) - ๐Ÿ”„ ๊ทธ๋ฆฌ๋“œ ๋ทฐ์™€ ๊ณ„์ธต๊ตฌ์กฐ ๋ทฐ ๊ฐ„ ์™„์ „ ๋™๊ธฐํ™” - ๐Ÿ›ก๏ธ ๊ด€๋ฆฌ์ž ์ „์šฉ ๋ฌธ์„œ ์‚ญ์ œ ๊ธฐ๋Šฅ - ๐Ÿ”ง ๋ชจ๋“  CORS ๋ฐ 500 ์˜ค๋ฅ˜ ํ•ด๊ฒฐ ๊ธฐ์ˆ ์  ๊ฐœ์„ : - API ๋ฒ ์ด์Šค URL์„ Nginx ํ”„๋ก์‹œ๋กœ ๋ณ€๊ฒฝ (/api) - ์™ธ๋ž˜ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด ํ•ด๊ฒฐ (์‚ญ์ œ ์ˆœ์„œ ์ตœ์ ํ™”) - SQLAlchemy ๊ด€๊ณ„ ๋กœ๋”ฉ ์ตœ์ ํ™” (selectinload) - ํ”„๋ก ํŠธ์—”๋“œ ์บ์‹œ ๋ฌดํšจํ™” ์‹œ์Šคํ…œ - Alpine.js ์ปดํฌ๋„ŒํŠธ ๊ตฌ์กฐ ๊ฐœ์„  UI/UX: - ๊ณ„์ธต๊ตฌ์กฐ ๋„ค๋น„๊ฒŒ์ด์…˜ (์‚ฌ์ด๋“œ๋ฐ” + ํŠธ๋ฆฌ ๊ตฌ์กฐ) - ํ•˜์ด๋ผ์ดํŠธ ๋ชจ๋“œ ํ† ๊ธ€ ์Šค์œ„์น˜ - ์™„์ „ํ•œ ํˆดํŒ ๊ธฐ๋ฐ˜ ๋ฉ”๋ชจ ๊ด€๋ฆฌ ์ธํ„ฐํŽ˜์ด์Šค - ๋ฐ˜์‘ํ˜• ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋‰ด (์ƒ‰์ƒ ์„ ํƒ) - ์Šค๋งˆํŠธ ํˆดํŒ ์œ„์น˜ ์กฐ์ • (ํ™”๋ฉด ๊ฒฝ๊ณ„ ๊ณ ๋ ค) --- backend/migrations/004_add_books_table.sql | 50 + backend/src/api/routes/book_categories.py | 155 +++ backend/src/api/routes/books.py | 230 ++++ backend/src/api/routes/documents.py | 198 +++- backend/src/api/routes/highlights.py | 129 ++- backend/src/main.py | 4 +- backend/src/models/book.py | 27 + backend/src/models/book_category.py | 26 + backend/src/models/document.py | 5 + backend/src/schemas/book.py | 32 + backend/src/schemas/book_category.py | 31 + frontend/hierarchy.html | 677 ++++++++++++ frontend/index.html | 139 ++- frontend/static/js/api.js | 94 +- frontend/static/js/auth.js | 10 +- frontend/static/js/hierarchy.js | 1115 ++++++++++++++++++++ frontend/static/js/main.js | 644 +++++------ frontend/test.html | 24 + 18 files changed, 3206 insertions(+), 384 deletions(-) create mode 100644 backend/migrations/004_add_books_table.sql create mode 100644 backend/src/api/routes/book_categories.py create mode 100644 backend/src/api/routes/books.py create mode 100644 backend/src/models/book.py create mode 100644 backend/src/models/book_category.py create mode 100644 backend/src/schemas/book.py create mode 100644 backend/src/schemas/book_category.py create mode 100644 frontend/hierarchy.html create mode 100644 frontend/static/js/hierarchy.js create mode 100644 frontend/test.html 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 @@
-

+

+ + +
+
+ + + +
+