🚀 배포용: PDF 뷰어 개선 및 서적별 UI 데본씽크 스타일 적용

 주요 개선사항:
- PDF API 500 에러 수정 (한글 파일명 UTF-8 인코딩 처리)
- PDF 뷰어 기능 완전 구현 (PDF.js 통합, 네비게이션, 확대/축소)
- 서적별 문서 그룹화 UI 데본씽크 스타일로 개선
- PDF Manager 페이지 서적별 보기 기능 추가
- Alpine.js 로드 순서 최적화로 JavaScript 에러 해결

🎨 UI/UX 개선:
- 확장/축소 가능한 아코디언 스타일 서적 목록
- 간결하고 직관적인 데본씽크 스타일 인터페이스
- PDF 상태 표시 (HTML 연결, 서적 분류)
- 반응형 디자인 및 부드러운 애니메이션

🔧 기술적 개선:
- PDF.js 워커 설정 및 토큰 인증 처리
- 서적별 PDF 자동 그룹화 로직
- Alpine.js 컴포넌트 초기화 최적화
This commit is contained in:
hyungi
2025-09-05 07:13:49 +09:00
commit cfb9485d4f
170 changed files with 41113 additions and 0 deletions

3
backend/src/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Document Server Backend Package
"""

View File

@@ -0,0 +1,3 @@
"""
API 패키지 초기화
"""

View File

@@ -0,0 +1,149 @@
"""
API 의존성
"""
from fastapi import Depends, HTTPException, status, Query
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from ..core.database import get_db
from ..core.security import verify_token, get_user_id_from_token
from ..models.user import User
# HTTP Bearer 토큰 스키마 (선택적)
security = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""현재 로그인된 사용자 가져오기"""
try:
# 토큰에서 사용자 ID 추출
user_id = get_user_id_from_token(credentials.credentials)
# 데이터베이스에서 사용자 조회
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
return user
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""활성 사용자 확인"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user
async def get_current_admin_user(
current_user: User = Depends(get_current_active_user)
) -> User:
"""관리자 권한 확인"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
async def get_optional_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db)
) -> Optional[User]:
"""선택적 사용자 인증 (토큰이 없어도 됨)"""
if not credentials:
return None
try:
return await get_current_user(credentials, db)
except HTTPException:
return None
async def get_current_user_with_token_param(
_token: Optional[str] = Query(None),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""URL 파라미터 또는 헤더에서 토큰을 가져와서 사용자 인증"""
print(f"🔍 토큰 인증 시작 - URL 파라미터: {_token[:50] if _token else 'None'}...")
print(f"🔍 Authorization 헤더: {credentials.credentials[:50] if credentials else 'None'}...")
token = None
# URL 파라미터에서 토큰 확인
if _token:
token = _token
print("✅ URL 파라미터에서 토큰 사용")
# Authorization 헤더에서 토큰 확인
elif credentials:
token = credentials.credentials
print("✅ Authorization 헤더에서 토큰 사용")
if not token:
print("❌ 토큰이 제공되지 않음")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No authentication token provided"
)
try:
# 토큰에서 사용자 ID 추출
user_id = get_user_id_from_token(token)
print(f"✅ 토큰에서 사용자 ID 추출: {user_id}")
# 데이터베이스에서 사용자 조회
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
print(f"❌ 사용자를 찾을 수 없음: {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if not user.is_active:
print(f"❌ 비활성 사용자: {user.email}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
print(f"✅ 사용자 인증 성공: {user.email}")
return user
except Exception as e:
print(f"🚫 토큰 인증 실패: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)

View File

@@ -0,0 +1,3 @@
"""
API 라우터 패키지 초기화
"""

View File

@@ -0,0 +1,193 @@
"""
인증 관련 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from datetime import datetime
from ...core.database import get_db
from ...core.security import verify_password, create_access_token, create_refresh_token, get_password_hash
from ...core.config import settings
from ...models.user import User
from ...schemas.auth import (
LoginRequest, TokenResponse, RefreshTokenRequest,
UserInfo, ChangePasswordRequest, CreateUserRequest
)
from ..dependencies import get_current_active_user, get_current_admin_user
router = APIRouter()
@router.post("/login", response_model=TokenResponse)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db)
):
"""사용자 로그인"""
# 사용자 조회
result = await db.execute(
select(User).where(User.email == login_data.email)
)
user = result.scalar_one_or_none()
# 사용자 존재 및 비밀번호 확인
if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
# 비활성 사용자 확인
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
# 사용자별 세션 타임아웃을 적용한 토큰 생성
access_token = create_access_token(
data={"sub": str(user.id)},
timeout_minutes=user.session_timeout_minutes
)
refresh_token = create_refresh_token(data={"sub": str(user.id)})
# 마지막 로그인 시간 업데이트
await db.execute(
update(User)
.where(User.id == user.id)
.values(last_login=datetime.utcnow())
)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
refresh_data: RefreshTokenRequest,
db: AsyncSession = Depends(get_db)
):
"""토큰 갱신"""
from ...core.security import verify_token
try:
# 리프레시 토큰 검증
payload = verify_token(refresh_data.refresh_token, token_type="refresh")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# 사용자 존재 확인
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
# 새 토큰 생성
access_token = create_access_token(data={"sub": str(user.id)})
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
@router.get("/me", response_model=UserInfo)
async def get_current_user_info(
current_user: User = Depends(get_current_active_user)
):
"""현재 사용자 정보 조회"""
return UserInfo.model_validate(current_user)
@router.put("/change-password")
async def change_password(
password_data: ChangePasswordRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""비밀번호 변경"""
# 현재 비밀번호 확인
if not verify_password(password_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password"
)
# 새 비밀번호 해싱 및 업데이트
new_hashed_password = get_password_hash(password_data.new_password)
await db.execute(
update(User)
.where(User.id == current_user.id)
.values(hashed_password=new_hashed_password)
)
await db.commit()
return {"message": "Password changed successfully"}
@router.post("/create-user", response_model=UserInfo)
async def create_user(
user_data: CreateUserRequest,
admin_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""새 사용자 생성 (관리자 전용)"""
# 이메일 중복 확인
result = await db.execute(
select(User).where(User.email == user_data.email)
)
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# 새 사용자 생성
new_user = User(
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
full_name=user_data.full_name,
is_admin=user_data.is_admin,
is_active=True
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return UserInfo.from_orm(new_user)
@router.post("/logout")
async def logout(
current_user: User = Depends(get_current_active_user)
):
"""로그아웃 (클라이언트에서 토큰 삭제)"""
# 실제로는 클라이언트에서 토큰을 삭제하면 됨
# 필요시 토큰 블랙리스트 구현 가능
return {"message": "Logged out successfully"}

View 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
)

View File

@@ -0,0 +1,300 @@
"""
책갈피 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_
from sqlalchemy.orm import joinedload
from typing import List, Optional
from datetime import datetime
from ...core.database import get_db
from ...models.user import User
from ...models.document import Document
from ...models.bookmark import Bookmark
from ..dependencies import get_current_active_user
from pydantic import BaseModel
class CreateBookmarkRequest(BaseModel):
"""책갈피 생성 요청"""
document_id: str
title: str
description: Optional[str] = None
page_number: Optional[int] = None
scroll_position: int = 0
element_id: Optional[str] = None
element_selector: Optional[str] = None
class UpdateBookmarkRequest(BaseModel):
"""책갈피 업데이트 요청"""
title: Optional[str] = None
description: Optional[str] = None
page_number: Optional[int] = None
scroll_position: Optional[int] = None
element_id: Optional[str] = None
element_selector: Optional[str] = None
class BookmarkResponse(BaseModel):
"""책갈피 응답"""
id: str
document_id: str
title: str
description: Optional[str]
page_number: Optional[int]
scroll_position: int
element_id: Optional[str]
element_selector: Optional[str]
created_at: datetime
updated_at: Optional[datetime]
document_title: str
class Config:
from_attributes = True
router = APIRouter()
@router.post("/", response_model=BookmarkResponse)
async def create_bookmark(
bookmark_data: CreateBookmarkRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 생성"""
# 문서 존재 및 권한 확인
result = await db.execute(select(Document).where(Document.id == bookmark_data.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"
)
# 책갈피 생성
bookmark = Bookmark(
user_id=current_user.id,
document_id=bookmark_data.document_id,
title=bookmark_data.title,
description=bookmark_data.description,
page_number=bookmark_data.page_number,
scroll_position=bookmark_data.scroll_position,
element_id=bookmark_data.element_id,
element_selector=bookmark_data.element_selector
)
db.add(bookmark)
await db.commit()
await db.refresh(bookmark)
# 응답 데이터 생성
response_data = BookmarkResponse.from_orm(bookmark)
response_data.document_title = document.title
return response_data
@router.get("/", response_model=List[BookmarkResponse])
async def list_user_bookmarks(
skip: int = 0,
limit: int = 50,
document_id: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""사용자의 모든 책갈피 조회"""
query = (
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(Bookmark.user_id == current_user.id)
)
if document_id:
query = query.where(Bookmark.document_id == document_id)
query = query.order_by(Bookmark.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
bookmarks = result.scalars().all()
# 응답 데이터 변환
response_data = []
for bookmark in bookmarks:
bookmark_data = BookmarkResponse.from_orm(bookmark)
bookmark_data.document_title = bookmark.document.title
response_data.append(bookmark_data)
return response_data
@router.get("/document/{document_id}", response_model=List[BookmarkResponse])
async def get_document_bookmarks(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 문서의 책갈피 목록 조회"""
# 문서 존재 및 권한 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 문서 접근 권한 확인
if 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(Bookmark)
.options(joinedload(Bookmark.document))
.where(
and_(
Bookmark.document_id == document_id,
Bookmark.user_id == current_user.id
)
)
.order_by(Bookmark.page_number, Bookmark.scroll_position)
)
bookmarks = result.scalars().all()
# 응답 데이터 변환
response_data = []
for bookmark in bookmarks:
bookmark_data = BookmarkResponse.from_orm(bookmark)
bookmark_data.document_title = bookmark.document.title
response_data.append(bookmark_data)
return response_data
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
async def get_bookmark(
bookmark_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 상세 조회"""
result = await db.execute(
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(Bookmark.id == bookmark_id)
)
bookmark = result.scalar_one_or_none()
if not bookmark:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bookmark not found"
)
# 소유자 확인
if bookmark.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
response_data = BookmarkResponse.from_orm(bookmark)
response_data.document_title = bookmark.document.title
return response_data
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
async def update_bookmark(
bookmark_id: str,
bookmark_data: UpdateBookmarkRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 업데이트"""
result = await db.execute(
select(Bookmark)
.options(joinedload(Bookmark.document))
.where(Bookmark.id == bookmark_id)
)
bookmark = result.scalar_one_or_none()
if not bookmark:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bookmark not found"
)
# 소유자 확인
if bookmark.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 업데이트
if bookmark_data.title is not None:
bookmark.title = bookmark_data.title
if bookmark_data.description is not None:
bookmark.description = bookmark_data.description
if bookmark_data.page_number is not None:
bookmark.page_number = bookmark_data.page_number
if bookmark_data.scroll_position is not None:
bookmark.scroll_position = bookmark_data.scroll_position
if bookmark_data.element_id is not None:
bookmark.element_id = bookmark_data.element_id
if bookmark_data.element_selector is not None:
bookmark.element_selector = bookmark_data.element_selector
await db.commit()
await db.refresh(bookmark)
response_data = BookmarkResponse.from_orm(bookmark)
response_data.document_title = bookmark.document.title
return response_data
@router.delete("/{bookmark_id}")
async def delete_bookmark(
bookmark_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""책갈피 삭제"""
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
bookmark = result.scalar_one_or_none()
if not bookmark:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bookmark not found"
)
# 소유자 확인
if bookmark.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 책갈피 삭제
await db.execute(delete(Bookmark).where(Bookmark.id == bookmark_id))
await db.commit()
return {"message": "Bookmark deleted successfully"}

View 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

View File

@@ -0,0 +1,690 @@
"""
문서 링크 관련 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from typing import List, Optional
from pydantic import BaseModel
import uuid
from ...core.database import get_db
from ..dependencies import get_current_active_user
from ...models import User, Document, DocumentLink
router = APIRouter()
# Pydantic 모델들
class DocumentLinkCreate(BaseModel):
target_document_id: str
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str] = None
description: Optional[str] = None
# 고급 링크 기능 (모두 Optional로 설정)
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: Optional[str] = "document" # "document" or "text_fragment"
class DocumentLinkUpdate(BaseModel):
target_document_id: Optional[str] = None
link_text: Optional[str] = None
description: Optional[str] = None
# 고급 링크 기능
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: Optional[str] = None
class DocumentLinkResponse(BaseModel):
id: str
source_document_id: str
target_document_id: str
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str]
description: Optional[str]
created_at: str
updated_at: Optional[str]
# 고급 링크 기능
target_text: Optional[str]
target_start_offset: Optional[int]
target_end_offset: Optional[int]
link_type: Optional[str] = "document"
# 대상 문서 정보
target_document_title: str
target_document_book_id: Optional[str]
target_content_type: Optional[str] = "document" # "document" 또는 "note"
class Config:
from_attributes = True
class LinkableDocumentResponse(BaseModel):
id: str
title: str
book_id: Optional[str]
book_title: Optional[str]
sort_order: int
class Config:
from_attributes = True
@router.post("/{document_id}/links", response_model=DocumentLinkResponse)
async def create_document_link(
document_id: str,
link_data: DocumentLinkCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 링크 생성"""
print(f"🔗 링크 생성 요청 - 문서 ID: {document_id}")
print(f"📋 링크 데이터: {link_data}")
print(f"🎯 target_text: '{link_data.target_text}'")
print(f"🎯 target_start_offset: {link_data.target_start_offset}")
print(f"🎯 target_end_offset: {link_data.target_end_offset}")
print(f"🎯 link_type: {link_data.link_type}")
if link_data.link_type == 'text_fragment' and not link_data.target_text:
print("🚨 CRITICAL: text_fragment 링크인데 target_text가 없습니다!")
# 출발 문서 확인
result = await db.execute(select(Document).where(Document.id == document_id))
source_doc = result.scalar_one_or_none()
if not source_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Source document not found"
)
# 권한 확인
if not source_doc.is_public and source_doc.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to source document"
)
# 대상 문서 또는 노트 확인
result = await db.execute(select(Document).where(Document.id == link_data.target_document_id))
target_doc = result.scalar_one_or_none()
target_note = None
if not target_doc:
# 문서에서 찾지 못하면 노트에서 찾기
print(f"🔍 문서에서 찾지 못함, 노트에서 검색: {link_data.target_document_id}")
from ...models.note_document import NoteDocument
result = await db.execute(select(NoteDocument).where(NoteDocument.id == link_data.target_document_id))
target_note = result.scalar_one_or_none()
if target_note:
print(f"✅ 노트 찾음: {target_note.title}")
else:
print(f"❌ 노트도 찾지 못함: {link_data.target_document_id}")
# 디버깅: 실제 존재하는 노트들 확인
all_notes_result = await db.execute(select(NoteDocument).limit(5))
all_notes = all_notes_result.scalars().all()
print(f"🔍 존재하는 노트 예시 (최대 5개):")
for note in all_notes:
print(f" - ID: {note.id}, 제목: {note.title}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Target document or note not found"
)
# 대상 문서/노트 권한 확인
if target_doc:
if not target_doc.is_public and target_doc.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to target document"
)
# HTML 문서만 링크 가능
if not target_doc.html_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only link to HTML documents"
)
elif target_note:
# 노트 권한 확인 (노트는 기본적으로 생성자만 접근 가능)
if target_note.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to target note"
)
# 링크 생성
new_link = DocumentLink(
source_document_id=uuid.UUID(document_id),
target_document_id=uuid.UUID(link_data.target_document_id),
selected_text=link_data.selected_text,
start_offset=link_data.start_offset,
end_offset=link_data.end_offset,
link_text=link_data.link_text,
description=link_data.description,
# 고급 링크 기능
target_text=link_data.target_text,
target_start_offset=link_data.target_start_offset,
target_end_offset=link_data.target_end_offset,
link_type=link_data.link_type,
created_by=current_user.id
)
db.add(new_link)
await db.commit()
await db.refresh(new_link)
target_title = target_doc.title if target_doc else target_note.title
target_type = "document" if target_doc else "note"
print(f"✅ 링크 생성 완료: {source_doc.title} -> {target_title} ({target_type})")
print(f" - 링크 타입: {new_link.link_type}")
print(f" - 선택된 텍스트: {new_link.selected_text}")
print(f" - 대상 텍스트: {new_link.target_text}")
# 백링크는 자동으로 생성되지 않음 - 기존 링크를 역방향으로 조회하는 방식 사용
# 응답 데이터 구성
return DocumentLinkResponse(
id=str(new_link.id),
source_document_id=str(new_link.source_document_id),
target_document_id=str(new_link.target_document_id),
selected_text=new_link.selected_text,
start_offset=new_link.start_offset,
end_offset=new_link.end_offset,
link_text=new_link.link_text,
description=new_link.description,
# 고급 링크 기능
target_text=new_link.target_text,
target_start_offset=new_link.target_start_offset,
target_end_offset=new_link.target_end_offset,
link_type=new_link.link_type,
created_at=new_link.created_at.isoformat(),
updated_at=new_link.updated_at.isoformat() if new_link.updated_at else None,
target_document_title=target_title,
target_document_book_id=str(target_doc.book_id) if target_doc and target_doc.book_id else (str(target_note.notebook_id) if target_note and target_note.notebook_id else None)
)
@router.get("/{document_id}/links", response_model=List[DocumentLinkResponse])
async def get_document_links(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서의 모든 링크 조회"""
# 문서 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if 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="Access denied"
)
# 모든 링크 조회 (문서→문서 + 문서→노트)
result = await db.execute(
select(DocumentLink)
.where(DocumentLink.source_document_id == document_id)
.order_by(DocumentLink.start_offset.asc())
)
all_links = result.scalars().all()
print(f"🔍 문서 링크 조회 완료: {len(all_links)}개 발견")
# 응답 데이터 구성
response_links = []
for link in all_links:
print(f"🔗 링크 처리 중: {link.id} -> {link.target_document_id}")
# 대상이 문서인지 노트인지 확인
target_doc = None
target_note = None
# 먼저 Document 테이블에서 찾기
doc_result = await db.execute(select(Document).where(Document.id == link.target_document_id))
target_doc = doc_result.scalar_one_or_none()
if target_doc:
print(f"✅ 대상 문서 찾음: {target_doc.title}")
target_title = target_doc.title
target_book_id = str(target_doc.book_id) if target_doc.book_id else None
target_content_type = "document"
else:
# Document에서 찾지 못하면 NoteDocument에서 찾기
from ...models.note_document import NoteDocument
note_result = await db.execute(select(NoteDocument).where(NoteDocument.id == link.target_document_id))
target_note = note_result.scalar_one_or_none()
if target_note:
print(f"✅ 대상 노트 찾음: {target_note.title}")
target_title = f"📝 {target_note.title}" # 노트임을 표시
target_book_id = str(target_note.notebook_id) if target_note.notebook_id else None
target_content_type = "note"
else:
print(f"❌ 대상을 찾을 수 없음: {link.target_document_id}")
target_title = "Unknown Target"
target_book_id = None
target_content_type = "document" # 기본값
response_links.append(DocumentLinkResponse(
id=str(link.id),
source_document_id=str(link.source_document_id),
target_document_id=str(link.target_document_id),
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
link_text=link.link_text,
description=link.description,
created_at=link.created_at.isoformat(),
updated_at=link.updated_at.isoformat() if link.updated_at else None,
# 고급 링크 기능 (기존 링크는 None일 수 있음)
target_text=getattr(link, 'target_text', None),
target_start_offset=getattr(link, 'target_start_offset', None),
target_end_offset=getattr(link, 'target_end_offset', None),
link_type=getattr(link, 'link_type', 'document'),
# 대상 문서/노트 정보 추가
target_document_title=target_title,
target_document_book_id=target_book_id,
target_content_type=target_content_type
))
return response_links
@router.get("/{document_id}/linkable-documents", response_model=List[LinkableDocumentResponse])
async def get_linkable_documents(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""링크 가능한 문서 목록 조회 (같은 서적 우선, 전체 HTML 문서)"""
# 현재 문서 확인
result = await db.execute(select(Document).where(Document.id == document_id))
current_doc = result.scalar_one_or_none()
if not current_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if not current_doc.is_public and current_doc.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 링크 가능한 HTML 문서들 조회
# 1. 같은 서적의 문서들 (우선순위)
# 2. 다른 서적의 문서들
from ...models import Book
query = select(Document, Book).outerjoin(Book, Document.book_id == Book.id).where(
and_(
Document.html_path.isnot(None), # HTML 문서만
Document.id != document_id, # 자기 자신 제외
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
)
).order_by(
# 같은 서적 우선, 그 다음 정렬 순서
(Document.book_id == current_doc.book_id).desc(),
Document.sort_order.asc().nulls_last(),
Document.created_at.asc()
)
result = await db.execute(query)
documents_with_books = result.all()
# 응답 데이터 구성
linkable_docs = []
for doc, book in documents_with_books:
linkable_docs.append(LinkableDocumentResponse(
id=str(doc.id),
title=doc.title,
book_id=str(doc.book_id) if doc.book_id else None,
book_title=book.title if book else None,
sort_order=doc.sort_order or 0
))
return linkable_docs
@router.put("/links/{link_id}", response_model=DocumentLinkResponse)
async def update_document_link(
link_id: str,
link_data: DocumentLinkUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 링크 수정"""
# 링크 조회
result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id))
link = result.scalar_one_or_none()
if not link:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Link not found"
)
# 권한 확인 (생성자만 수정 가능)
if link.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 대상 문서 변경 시 검증
if link_data.target_document_id:
result = await db.execute(select(Document).where(Document.id == link_data.target_document_id))
target_doc = result.scalar_one_or_none()
if not target_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Target document not found"
)
if not target_doc.html_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only link to HTML documents"
)
link.target_document_id = uuid.UUID(link_data.target_document_id)
# 필드 업데이트
if link_data.link_text is not None:
link.link_text = link_data.link_text
if link_data.description is not None:
link.description = link_data.description
await db.commit()
await db.refresh(link)
# 대상 문서 정보 조회
result = await db.execute(select(Document).where(Document.id == link.target_document_id))
target_doc = result.scalar_one()
return DocumentLinkResponse(
id=str(link.id),
source_document_id=str(link.source_document_id),
target_document_id=str(link.target_document_id),
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
link_text=link.link_text,
description=link.description,
created_at=link.created_at.isoformat(),
updated_at=link.updated_at.isoformat() if link.updated_at else None,
target_document_title=target_doc.title,
target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None
)
@router.delete("/links/{link_id}")
@router.delete("/document-links/{link_id}") # 프론트엔드 호환성을 위한 추가 경로
async def delete_document_link(
link_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 링크 삭제"""
# 링크 조회
result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id))
link = result.scalar_one_or_none()
if not link:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Link not found"
)
# 권한 확인 (생성자만 삭제 가능)
if link.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
await db.delete(link)
await db.commit()
return {"message": "Link deleted successfully"}
# 백링크 관련 모델
class BacklinkResponse(BaseModel):
id: str
source_document_id: str
source_document_title: str
source_document_book_id: Optional[str]
source_content_type: Optional[str] = "document" # "document" or "note"
target_document_id: str
target_document_title: str
selected_text: str # 소스 문서에서 선택한 텍스트
start_offset: int # 소스 문서 오프셋
end_offset: int # 소스 문서 오프셋
link_text: Optional[str]
description: Optional[str]
link_type: str
target_text: Optional[str] # 🎯 타겟 문서의 텍스트 (백링크 렌더링용)
target_start_offset: Optional[int] # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
target_end_offset: Optional[int] # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
created_at: str
class Config:
from_attributes = True
@router.get("/{document_id}/backlinks", response_model=List[BacklinkResponse])
async def get_document_backlinks(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서의 백링크 조회 (이 문서를 참조하는 모든 링크)"""
print(f"🔍 백링크 API 호출됨 - 문서 ID: {document_id}, 사용자: {current_user.email}")
# 문서 존재 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
print(f"❌ 문서를 찾을 수 없음: {document_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
print(f"✅ 문서 찾음: {document.title}")
# 권한 확인
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="Access denied"
)
# 이 문서를 대상으로 하는 모든 링크 조회 (백링크)
from ...models import Book
from ...models.note_link import NoteLink
from ...models.note_document import NoteDocument
from ...models.notebook import Notebook
# 1. 일반 문서에서 오는 백링크 (DocumentLink)
doc_query = select(DocumentLink, Document, Book).join(
Document, DocumentLink.source_document_id == Document.id
).outerjoin(Book, Document.book_id == Book.id).where(
and_(
DocumentLink.target_document_id == document_id,
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
)
).order_by(DocumentLink.created_at.desc())
doc_result = await db.execute(doc_query)
backlinks = []
print(f"🔍 문서 백링크 쿼리 실행 완료")
# 일반 문서 백링크 처리
for link, source_doc, book in doc_result.fetchall():
print(f"📋 백링크 발견: {source_doc.title} -> {document.title}")
print(f" - 소스 텍스트 (selected_text): {link.selected_text}")
print(f" - 타겟 텍스트 (target_text): {link.target_text}")
print(f" - 타겟 오프셋: {link.target_start_offset}-{link.target_end_offset}")
print(f" - 링크 타입: {link.link_type}")
backlinks.append(BacklinkResponse(
id=str(link.id),
source_document_id=str(link.source_document_id),
source_document_title=source_doc.title,
source_document_book_id=str(book.id) if book else None,
source_content_type="document", # 일반 문서
target_document_id=str(link.target_document_id),
target_document_title=document.title,
selected_text=link.selected_text, # 소스 문서에서 선택한 텍스트 (참고용)
start_offset=link.start_offset, # 소스 문서 오프셋 (참고용)
end_offset=link.end_offset, # 소스 문서 오프셋 (참고용)
link_text=link.link_text,
description=link.description,
link_type=link.link_type,
target_text=link.target_text, # 🎯 타겟 문서의 텍스트 (백링크 렌더링용)
target_start_offset=link.target_start_offset, # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
target_end_offset=link.target_end_offset, # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
created_at=link.created_at.isoformat()
))
# 2. 노트에서 오는 백링크 (NoteLink) - 동기 쿼리 사용
try:
from ...core.database import get_sync_db
sync_db = next(get_sync_db())
# 노트에서 이 문서를 대상으로 하는 링크들 조회
note_links = sync_db.query(NoteLink).join(
NoteDocument, NoteLink.source_note_id == NoteDocument.id
).outerjoin(Notebook, NoteDocument.notebook_id == Notebook.id).filter(
NoteLink.target_document_id == document_id
).all()
print(f"🔍 노트 백링크 쿼리 실행 완료: {len(note_links)}개 발견")
# 노트 백링크 처리
for link in note_links:
source_note = sync_db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first()
notebook = sync_db.query(Notebook).filter(Notebook.id == source_note.notebook_id).first() if source_note else None
if source_note:
print(f"📋 노트 백링크 발견: {source_note.title} -> {document.title}")
print(f" - 소스 텍스트 (selected_text): {link.selected_text}")
print(f" - 타겟 텍스트 (target_text): {link.target_text}")
print(f" - 타겟 오프셋: {link.target_start_offset}-{link.target_end_offset}")
print(f" - 링크 타입: {link.link_type}")
backlinks.append(BacklinkResponse(
id=str(link.id),
source_document_id=str(link.source_note_id), # 노트 ID를 문서 ID로 사용
source_document_title=f"📝 {source_note.title}", # 노트임을 표시
source_document_book_id=str(notebook.id) if notebook else None,
source_content_type="note", # 노트 문서
target_document_id=str(link.target_document_id) if link.target_document_id else document_id,
target_document_title=document.title,
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
link_text=link.link_text,
description=link.description,
link_type=link.link_type,
target_text=link.target_text,
target_start_offset=link.target_start_offset,
target_end_offset=link.target_end_offset,
created_at=link.created_at.isoformat() if link.created_at else None
))
sync_db.close()
except Exception as e:
print(f"❌ 노트 백링크 조회 실패: {e}")
print(f"✅ 총 {len(backlinks)}개의 백링크 반환 (문서 + 노트)")
return backlinks
@router.get("/{document_id}/link-fragments")
async def get_document_link_fragments(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 내 모든 링크된 텍스트 조각 조회 (중복 링크 관리용)"""
# 문서 존재 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if 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="Access denied"
)
# 이 문서에서 출발하는 모든 링크 조회
query = select(DocumentLink, Document).join(
Document, DocumentLink.target_document_id == Document.id
).where(
and_(
DocumentLink.source_document_id == document_id,
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
)
).order_by(DocumentLink.start_offset.asc())
result = await db.execute(query)
fragments = []
for link, target_doc in result.fetchall():
fragments.append({
"link_id": str(link.id),
"start_offset": link.start_offset,
"end_offset": link.end_offset,
"selected_text": link.selected_text,
"target_document_id": str(link.target_document_id),
"target_document_title": target_doc.title,
"link_text": link.link_text,
"description": link.description,
"link_type": link.link_type,
"target_text": link.target_text,
"target_start_offset": link.target_start_offset,
"target_end_offset": link.target_end_offset
})
return fragments

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,471 @@
"""
하이라이트 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import datetime
from uuid import UUID
from ...core.database import get_db
from ...models.user import User
from ...models.document import Document
from ...models.highlight import Highlight
from ...models.note import Note
from ..dependencies import get_current_active_user
from pydantic import BaseModel
class CreateHighlightRequest(BaseModel):
"""하이라이트 생성 요청"""
document_id: str
start_offset: int
end_offset: int
selected_text: str
element_selector: Optional[str] = None
start_container_xpath: Optional[str] = None
end_container_xpath: Optional[str] = None
highlight_color: str = "#FFFF00"
highlight_type: str = "highlight"
note_content: Optional[str] = None # 바로 메모 추가
class UpdateHighlightRequest(BaseModel):
"""하이라이트 업데이트 요청"""
highlight_color: Optional[str] = None
highlight_type: Optional[str] = None
note: Optional[str] = None # 메모 업데이트 지원
class HighlightResponse(BaseModel):
"""하이라이트 응답"""
id: str
user_id: str
document_id: str
start_offset: int
end_offset: int
selected_text: str
element_selector: Optional[str]
start_container_xpath: Optional[str]
end_container_xpath: Optional[str]
highlight_color: str
highlight_type: str
created_at: datetime
updated_at: Optional[datetime]
note: Optional[dict] = None # 연결된 메모 정보
class Config:
from_attributes = True
router = APIRouter()
@router.post("/", response_model=HighlightResponse)
async def create_highlight(
highlight_data: CreateHighlightRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""하이라이트 생성 (메모 포함 가능)"""
# 문서 존재 및 권한 확인
result = await db.execute(select(Document).where(Document.id == highlight_data.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"
)
# 하이라이트 생성
highlight = Highlight(
user_id=current_user.id,
document_id=highlight_data.document_id,
start_offset=highlight_data.start_offset,
end_offset=highlight_data.end_offset,
selected_text=highlight_data.selected_text,
element_selector=highlight_data.element_selector,
start_container_xpath=highlight_data.start_container_xpath,
end_container_xpath=highlight_data.end_container_xpath,
highlight_color=highlight_data.highlight_color,
highlight_type=highlight_data.highlight_type
)
db.add(highlight)
await db.flush() # ID 생성을 위해
# 메모가 있으면 함께 생성
note = None
if highlight_data.note_content:
note = Note(
highlight_id=highlight.id,
content=highlight_data.note_content
)
db.add(note)
await db.commit()
await db.refresh(highlight)
# 응답 데이터 생성 (Pydantic v2 호환)
response_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
)
if note:
response_data.note = {
"id": str(note.id),
"content": note.content,
"tags": note.tags,
"created_at": note.created_at.isoformat(),
"updated_at": note.updated_at.isoformat() if note.updated_at else None
}
return response_data
@router.get("/document/{document_id}", response_model=List[HighlightResponse])
async def get_document_highlights(
document_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 문서의 하이라이트 목록 조회"""
try:
print(f"DEBUG: Getting highlights for document {document_id}, user {current_user.id}")
# 문서 존재 및 권한 확인
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"
)
# 사용자의 하이라이트만 조회 (연관된 메모도 함께 로드)
result = await db.execute(
select(Highlight)
.options(selectinload(Highlight.notes)) # 메모 관계 로드
.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:
# 연관된 메모 정보 포함 (notes는 리스트이므로 첫 번째 메모 사용)
note_data = None
if highlight.notes and len(highlight.notes) > 0:
first_note = highlight.notes[0] # 첫 번째 메모 사용
note_data = {
"id": str(first_note.id),
"content": first_note.content,
"created_at": first_note.created_at.isoformat(),
"updated_at": first_note.updated_at.isoformat() if first_note.updated_at else None
}
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=note_data
)
response_data.append(highlight_data)
return response_data
except Exception as e:
print(f"ERROR in get_document_highlights: {e}")
import traceback
traceback.print_exc()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal server error: {str(e)}"
)
@router.get("/{highlight_id}", response_model=HighlightResponse)
async def get_highlight(
highlight_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""하이라이트 상세 조회"""
result = await db.execute(
select(Highlight)
.options(selectinload(Highlight.user))
.where(Highlight.id == highlight_id)
)
highlight = result.scalar_one_or_none()
if not highlight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Highlight not found"
)
# 소유자 확인
if highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
response_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
)
if highlight.notes:
response_data.note = {
"id": str(highlight.notes.id),
"content": highlight.notes.content,
"tags": highlight.notes.tags,
"created_at": highlight.notes.created_at.isoformat(),
"updated_at": highlight.notes.updated_at.isoformat() if highlight.notes.updated_at else None
}
return response_data
@router.put("/{highlight_id}", response_model=HighlightResponse)
async def update_highlight(
highlight_id: str,
highlight_data: UpdateHighlightRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""하이라이트 업데이트"""
result = await db.execute(
select(Highlight)
.options(selectinload(Highlight.user), selectinload(Highlight.notes))
.where(Highlight.id == highlight_id)
)
highlight = result.scalar_one_or_none()
if not highlight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Highlight not found"
)
# 소유자 확인
if highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 업데이트
if highlight_data.highlight_color:
highlight.highlight_color = highlight_data.highlight_color
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)
response_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
)
if highlight.notes:
response_data.note = {
"id": str(highlight.notes.id),
"content": highlight.notes.content,
"tags": highlight.notes.tags,
"created_at": highlight.notes.created_at.isoformat(),
"updated_at": highlight.notes.updated_at.isoformat() if highlight.notes.updated_at else None
}
return response_data
@router.delete("/{highlight_id}")
async def delete_highlight(
highlight_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""하이라이트 삭제 (연결된 메모도 함께 삭제)"""
result = await db.execute(select(Highlight).where(Highlight.id == highlight_id))
highlight = result.scalar_one_or_none()
if not highlight:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Highlight not found"
)
# 소유자 확인
if highlight.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 안전한 하이라이트 삭제 (연결된 메모 먼저 삭제)
try:
print(f"DEBUG: Starting deletion of highlight {highlight_id}")
# 1. 먼저 연결된 메모 삭제
from ...models.note import Note
note_result = await db.execute(delete(Note).where(Note.highlight_id == highlight_id))
print(f"DEBUG: Deleted {note_result.rowcount} notes for highlight {highlight_id}")
# 2. 하이라이트 삭제
highlight_result = await db.execute(delete(Highlight).where(Highlight.id == highlight_id))
print(f"DEBUG: Deleted {highlight_result.rowcount} highlights")
# 3. 커밋
await db.commit()
print(f"DEBUG: Successfully deleted highlight {highlight_id}")
except Exception as e:
print(f"ERROR: Failed to delete highlight {highlight_id}: {e}")
await db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete highlight: {str(e)}"
)
return {"message": "Highlight deleted successfully"}
@router.get("/", response_model=List[HighlightResponse])
async def list_user_highlights(
skip: int = 0,
limit: int = 50,
document_id: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""사용자의 모든 하이라이트 조회"""
query = select(Highlight).options(selectinload(Highlight.user)).where(
Highlight.user_id == current_user.id
)
if document_id:
query = query.where(Highlight.document_id == document_id)
query = query.order_by(Highlight.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
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

View File

@@ -0,0 +1,700 @@
"""
트리 구조 메모장 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func, and_, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from uuid import UUID
from ...core.database import get_db
from ...models.user import User
from ...models.memo_tree import MemoTree, MemoNode, MemoNodeVersion, MemoTreeShare
from ...schemas.memo_tree import (
MemoTreeCreate, MemoTreeUpdate, MemoTreeResponse, MemoTreeWithNodes,
MemoNodeCreate, MemoNodeUpdate, MemoNodeResponse, MemoNodeMove,
MemoTreeStats, MemoSearchRequest, MemoSearchResult
)
from ..dependencies import get_current_active_user
router = APIRouter(prefix="/memo-trees", tags=["memo-trees"])
# ============================================================================
# 메모 트리 관리
# ============================================================================
@router.get("/", response_model=List[MemoTreeResponse])
async def get_user_memo_trees(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
include_archived: bool = Query(False, description="보관된 트리 포함 여부")
):
"""사용자의 메모 트리 목록 조회"""
try:
query = select(MemoTree).where(MemoTree.user_id == current_user.id)
if not include_archived:
query = query.where(MemoTree.is_archived == False)
query = query.order_by(MemoTree.updated_at.desc())
result = await db.execute(query)
trees = result.scalars().all()
# 각 트리의 노드 개수 계산
tree_responses = []
for tree in trees:
node_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
)
node_count = node_count_result.scalar() or 0
tree_dict = {
"id": str(tree.id),
"user_id": str(tree.user_id),
"title": tree.title,
"description": tree.description,
"tree_type": tree.tree_type,
"template_data": tree.template_data,
"settings": tree.settings,
"created_at": tree.created_at,
"updated_at": tree.updated_at,
"is_public": tree.is_public,
"is_archived": tree.is_archived,
"node_count": node_count
}
tree_responses.append(MemoTreeResponse(**tree_dict))
return tree_responses
except Exception as e:
print(f"ERROR in get_user_memo_trees: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get memo trees: {str(e)}"
)
@router.post("/", response_model=MemoTreeResponse)
async def create_memo_tree(
tree_data: MemoTreeCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새 메모 트리 생성"""
try:
new_tree = MemoTree(
user_id=current_user.id,
title=tree_data.title,
description=tree_data.description,
tree_type=tree_data.tree_type,
template_data=tree_data.template_data or {},
settings=tree_data.settings or {},
is_public=tree_data.is_public
)
db.add(new_tree)
await db.commit()
await db.refresh(new_tree)
tree_dict = {
"id": str(new_tree.id),
"user_id": str(new_tree.user_id),
"title": new_tree.title,
"description": new_tree.description,
"tree_type": new_tree.tree_type,
"template_data": new_tree.template_data,
"settings": new_tree.settings,
"created_at": new_tree.created_at,
"updated_at": new_tree.updated_at,
"is_public": new_tree.is_public,
"is_archived": new_tree.is_archived,
"node_count": 0
}
return MemoTreeResponse(**tree_dict)
except Exception as e:
await db.rollback()
print(f"ERROR in create_memo_tree: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create memo tree: {str(e)}"
)
@router.get("/{tree_id}", response_model=MemoTreeResponse)
async def get_memo_tree(
tree_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 트리 상세 조회"""
try:
result = await db.execute(
select(MemoTree).where(
and_(
MemoTree.id == tree_id,
or_(
MemoTree.user_id == current_user.id,
MemoTree.is_public == True
)
)
)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo tree not found"
)
# 노드 개수 계산
node_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
)
node_count = node_count_result.scalar() or 0
tree_dict = {
"id": str(tree.id),
"user_id": str(tree.user_id),
"title": tree.title,
"description": tree.description,
"tree_type": tree.tree_type,
"template_data": tree.template_data,
"settings": tree.settings,
"created_at": tree.created_at,
"updated_at": tree.updated_at,
"is_public": tree.is_public,
"is_archived": tree.is_archived,
"node_count": node_count
}
return MemoTreeResponse(**tree_dict)
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_memo_tree: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get memo tree: {str(e)}"
)
@router.put("/{tree_id}", response_model=MemoTreeResponse)
async def update_memo_tree(
tree_id: UUID,
tree_data: MemoTreeUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 트리 업데이트"""
try:
result = await db.execute(
select(MemoTree).where(
and_(
MemoTree.id == tree_id,
MemoTree.user_id == current_user.id
)
)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo tree not found"
)
# 업데이트할 필드들 적용
update_data = tree_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(tree, field, value)
await db.commit()
await db.refresh(tree)
# 노드 개수 계산
node_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
)
node_count = node_count_result.scalar() or 0
tree_dict = {
"id": str(tree.id),
"user_id": str(tree.user_id),
"title": tree.title,
"description": tree.description,
"tree_type": tree.tree_type,
"template_data": tree.template_data,
"settings": tree.settings,
"created_at": tree.created_at,
"updated_at": tree.updated_at,
"is_public": tree.is_public,
"is_archived": tree.is_archived,
"node_count": node_count
}
return MemoTreeResponse(**tree_dict)
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in update_memo_tree: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update memo tree: {str(e)}"
)
@router.delete("/{tree_id}")
async def delete_memo_tree(
tree_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 트리 삭제"""
try:
result = await db.execute(
select(MemoTree).where(
and_(
MemoTree.id == tree_id,
MemoTree.user_id == current_user.id
)
)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo tree not found"
)
# 트리 삭제 (CASCADE로 관련 노드들도 자동 삭제됨)
await db.delete(tree)
await db.commit()
return {"message": "Memo tree deleted successfully"}
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in delete_memo_tree: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete memo tree: {str(e)}"
)
# ============================================================================
# 메모 노드 관리
# ============================================================================
@router.get("/{tree_id}/nodes", response_model=List[MemoNodeResponse])
async def get_memo_tree_nodes(
tree_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 트리의 모든 노드 조회"""
try:
# 트리 접근 권한 확인
tree_result = await db.execute(
select(MemoTree).where(
and_(
MemoTree.id == tree_id,
or_(
MemoTree.user_id == current_user.id,
MemoTree.is_public == True
)
)
)
)
tree = tree_result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo tree not found"
)
# 노드들 조회
result = await db.execute(
select(MemoNode)
.where(MemoNode.tree_id == tree_id)
.order_by(MemoNode.path, MemoNode.sort_order)
)
nodes = result.scalars().all()
# 각 노드의 자식 개수 계산
node_responses = []
for node in nodes:
children_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
)
children_count = children_count_result.scalar() or 0
node_dict = {
"id": str(node.id),
"tree_id": str(node.tree_id),
"parent_id": str(node.parent_id) if node.parent_id else None,
"user_id": str(node.user_id),
"title": node.title,
"content": node.content,
"node_type": node.node_type,
"sort_order": node.sort_order,
"depth_level": node.depth_level,
"path": node.path,
"tags": node.tags or [],
"node_metadata": node.node_metadata or {},
"status": node.status,
"word_count": node.word_count,
"is_canonical": node.is_canonical,
"canonical_order": node.canonical_order,
"story_path": node.story_path,
"created_at": node.created_at,
"updated_at": node.updated_at,
"children_count": children_count
}
node_responses.append(MemoNodeResponse(**node_dict))
return node_responses
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_memo_tree_nodes: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get memo tree nodes: {str(e)}"
)
@router.post("/{tree_id}/nodes", response_model=MemoNodeResponse)
async def create_memo_node(
tree_id: UUID,
node_data: MemoNodeCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새 메모 노드 생성"""
try:
# 트리 접근 권한 확인
tree_result = await db.execute(
select(MemoTree).where(
and_(
MemoTree.id == tree_id,
MemoTree.user_id == current_user.id
)
)
)
tree = tree_result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo tree not found"
)
# 부모 노드 확인 (있다면)
if node_data.parent_id:
parent_result = await db.execute(
select(MemoNode).where(
and_(
MemoNode.id == UUID(node_data.parent_id),
MemoNode.tree_id == tree_id
)
)
)
parent_node = parent_result.scalar_one_or_none()
if not parent_node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent node not found"
)
# 단어 수 계산
word_count = 0
if node_data.content:
word_count = len(node_data.content.replace('\n', ' ').split())
new_node = MemoNode(
tree_id=tree_id,
parent_id=UUID(node_data.parent_id) if node_data.parent_id else None,
user_id=current_user.id,
title=node_data.title,
content=node_data.content,
node_type=node_data.node_type,
sort_order=node_data.sort_order,
tags=node_data.tags or [],
node_metadata=node_data.node_metadata or {},
status=node_data.status,
word_count=word_count,
is_canonical=node_data.is_canonical or False
)
db.add(new_node)
await db.commit()
await db.refresh(new_node)
node_dict = {
"id": str(new_node.id),
"tree_id": str(new_node.tree_id),
"parent_id": str(new_node.parent_id) if new_node.parent_id else None,
"user_id": str(new_node.user_id),
"title": new_node.title,
"content": new_node.content,
"node_type": new_node.node_type,
"sort_order": new_node.sort_order,
"depth_level": new_node.depth_level,
"path": new_node.path,
"tags": new_node.tags or [],
"node_metadata": new_node.node_metadata or {},
"status": new_node.status,
"word_count": new_node.word_count,
"is_canonical": new_node.is_canonical,
"canonical_order": new_node.canonical_order,
"story_path": new_node.story_path,
"created_at": new_node.created_at,
"updated_at": new_node.updated_at,
"children_count": 0
}
return MemoNodeResponse(**node_dict)
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in create_memo_node: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create memo node: {str(e)}"
)
@router.get("/nodes/{node_id}", response_model=MemoNodeResponse)
async def get_memo_node(
node_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 노드 상세 조회"""
try:
result = await db.execute(
select(MemoNode)
.options(selectinload(MemoNode.tree))
.where(MemoNode.id == node_id)
)
node = result.scalar_one_or_none()
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo node not found"
)
# 접근 권한 확인
if node.tree.user_id != current_user.id and not node.tree.is_public:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this node"
)
# 자식 개수 계산
children_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
)
children_count = children_count_result.scalar() or 0
node_dict = {
"id": str(node.id),
"tree_id": str(node.tree_id),
"parent_id": str(node.parent_id) if node.parent_id else None,
"user_id": str(node.user_id),
"title": node.title,
"content": node.content,
"node_type": node.node_type,
"sort_order": node.sort_order,
"depth_level": node.depth_level,
"path": node.path,
"tags": node.tags or [],
"node_metadata": node.node_metadata or {},
"status": node.status,
"word_count": node.word_count,
"is_canonical": node.is_canonical,
"canonical_order": node.canonical_order,
"story_path": node.story_path,
"created_at": node.created_at,
"updated_at": node.updated_at,
"children_count": children_count
}
return MemoNodeResponse(**node_dict)
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_memo_node: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get memo node: {str(e)}"
)
@router.put("/nodes/{node_id}", response_model=MemoNodeResponse)
async def update_memo_node(
node_id: UUID,
node_data: MemoNodeUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 노드 업데이트"""
try:
result = await db.execute(
select(MemoNode)
.options(selectinload(MemoNode.tree))
.where(MemoNode.id == node_id)
)
node = result.scalar_one_or_none()
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo node not found"
)
# 접근 권한 확인 (소유자만 수정 가능)
if node.tree.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this node"
)
# 업데이트할 필드들 적용
update_data = node_data.dict(exclude_unset=True)
for field, value in update_data.items():
if field == "parent_id" and value:
# 부모 노드 유효성 검사
parent_result = await db.execute(
select(MemoNode).where(
and_(
MemoNode.id == UUID(value),
MemoNode.tree_id == node.tree_id
)
)
)
parent_node = parent_result.scalar_one_or_none()
if not parent_node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent node not found"
)
setattr(node, field, UUID(value))
elif field == "parent_id" and value is None:
setattr(node, field, None)
else:
setattr(node, field, value)
# 내용이 업데이트되면 단어 수 재계산
if "content" in update_data:
word_count = 0
if node.content:
word_count = len(node.content.replace('\n', ' ').split())
node.word_count = word_count
await db.commit()
await db.refresh(node)
# 자식 개수 계산
children_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
)
children_count = children_count_result.scalar() or 0
node_dict = {
"id": str(node.id),
"tree_id": str(node.tree_id),
"parent_id": str(node.parent_id) if node.parent_id else None,
"user_id": str(node.user_id),
"title": node.title,
"content": node.content,
"node_type": node.node_type,
"sort_order": node.sort_order,
"depth_level": node.depth_level,
"path": node.path,
"tags": node.tags or [],
"node_metadata": node.node_metadata or {},
"status": node.status,
"word_count": node.word_count,
"is_canonical": node.is_canonical,
"canonical_order": node.canonical_order,
"story_path": node.story_path,
"created_at": node.created_at,
"updated_at": node.updated_at,
"children_count": children_count
}
return MemoNodeResponse(**node_dict)
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in update_memo_node: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update memo node: {str(e)}"
)
@router.delete("/nodes/{node_id}")
async def delete_memo_node(
node_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 노드 삭제"""
try:
result = await db.execute(
select(MemoNode)
.options(selectinload(MemoNode.tree))
.where(MemoNode.id == node_id)
)
node = result.scalar_one_or_none()
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo node not found"
)
# 접근 권한 확인 (소유자만 삭제 가능)
if node.tree.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to delete this node"
)
# 노드 삭제 (CASCADE로 자식 노드들도 자동 삭제됨)
await db.delete(node)
await db.commit()
return {"message": "Memo node deleted successfully"}
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in delete_memo_node: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete memo node: {str(e)}"
)

View File

@@ -0,0 +1,271 @@
"""
노트 문서 관련 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, asc
from typing import List, Optional
import html
from ...core.database import get_sync_db
from ..dependencies import get_current_user
from ...models.user import User
from ...models.note_document import (
NoteDocument,
NoteDocumentCreate,
NoteDocumentUpdate,
NoteDocumentResponse,
NoteDocumentListItem,
NoteStats
)
from ...models.notebook import Notebook
router = APIRouter()
def calculate_reading_time(content: str) -> int:
"""HTML 내용에서 예상 읽기 시간 계산 (분)"""
if not content:
return 0
# HTML 태그 제거
text_content = html.unescape(content)
# 간단한 HTML 태그 제거 (정확하지 않지만 대략적인 계산용)
import re
text_content = re.sub(r'<[^>]+>', '', text_content)
# 단어 수 계산 (한국어 + 영어)
words = len(text_content.split())
korean_chars = len([c for c in text_content if '\uac00' <= c <= '\ud7af'])
# 대략적인 읽기 속도: 영어 200단어/분, 한국어 300자/분
english_time = words / 200
korean_time = korean_chars / 300
return max(1, int(english_time + korean_time))
@router.get("/", response_model=List[NoteDocumentListItem])
def get_note_documents(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: Optional[str] = Query(None),
note_type: Optional[str] = Query(None),
published_only: bool = Query(False),
notebook_id: Optional[str] = Query(None),
sort_by: str = Query("updated_at", regex="^(title|created_at|updated_at|word_count)$"),
order: str = Query("desc", regex="^(asc|desc)$"),
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 문서 목록 조회"""
query = db.query(NoteDocument)
# 필터링
if search:
search_term = f"%{search}%"
query = query.filter(
(NoteDocument.title.ilike(search_term)) |
(NoteDocument.content.ilike(search_term))
)
if note_type:
query = query.filter(NoteDocument.note_type == note_type)
if published_only:
query = query.filter(NoteDocument.is_published == True)
if notebook_id:
query = query.filter(NoteDocument.notebook_id == notebook_id)
# 정렬
if sort_by == 'title':
query = query.order_by(asc(NoteDocument.title) if order == 'asc' else desc(NoteDocument.title))
elif sort_by == 'created_at':
query = query.order_by(asc(NoteDocument.created_at) if order == 'asc' else desc(NoteDocument.created_at))
elif sort_by == 'word_count':
query = query.order_by(asc(NoteDocument.word_count) if order == 'asc' else desc(NoteDocument.word_count))
else:
query = query.order_by(desc(NoteDocument.updated_at))
# 페이지네이션
notes = query.offset(skip).limit(limit).all()
# 자식 노트 개수 계산
result = []
for note in notes:
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == note.id
).scalar()
note_item = NoteDocumentListItem.from_orm(note, child_count)
result.append(note_item)
return result
@router.get("/stats", response_model=NoteStats)
def get_note_stats(
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 통계 정보"""
total_notes = db.query(func.count(NoteDocument.id)).scalar()
published_notes = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.is_published == True
).scalar()
draft_notes = total_notes - published_notes
# 노트 타입별 통계
type_stats = db.query(
NoteDocument.note_type,
func.count(NoteDocument.id)
).group_by(NoteDocument.note_type).all()
note_types = {note_type: count for note_type, count in type_stats}
# 총 단어 수와 읽기 시간
total_words = db.query(func.sum(NoteDocument.word_count)).scalar() or 0
total_reading_time = db.query(func.sum(NoteDocument.reading_time)).scalar() or 0
# 최근 노트들
recent_notes_query = db.query(NoteDocument).order_by(
desc(NoteDocument.updated_at)
).limit(5).all()
recent_notes = [NoteDocumentListItem.from_orm(note) for note in recent_notes_query]
return NoteStats(
total_notes=total_notes,
published_notes=published_notes,
draft_notes=draft_notes,
note_types=note_types,
total_words=total_words,
total_reading_time=total_reading_time,
recent_notes=recent_notes
)
@router.get("/{note_id}", response_model=NoteDocumentResponse)
def get_note_document(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 노트 문서 조회"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
return NoteDocumentResponse.from_orm(note)
@router.post("/", response_model=NoteDocumentResponse)
def create_note_document(
note_data: NoteDocumentCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""새 노트 문서 생성"""
# 단어 수 및 읽기 시간 계산
word_count = len(note_data.content or '') if note_data.content else 0
reading_time = calculate_reading_time(note_data.content or '')
note = NoteDocument(
title=note_data.title,
content=note_data.content,
note_type=note_data.note_type,
tags=note_data.tags,
is_published=note_data.is_published,
parent_note_id=note_data.parent_note_id,
sort_order=note_data.sort_order,
notebook_id=note_data.notebook_id,
created_by=current_user.email,
word_count=word_count,
reading_time=reading_time
)
db.add(note)
db.commit()
db.refresh(note)
return NoteDocumentResponse.from_orm(note)
@router.put("/{note_id}", response_model=NoteDocumentResponse)
def update_note_document(
note_id: str,
note_data: NoteDocumentUpdate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 문서 업데이트"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 권한 확인
if note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 업데이트할 필드만 적용
update_data = note_data.dict(exclude_unset=True)
# 내용이 변경되면 단어 수와 읽기 시간 재계산
if 'content' in update_data:
update_data['word_count'] = len(update_data['content'] or '')
update_data['reading_time'] = calculate_reading_time(update_data['content'] or '')
for field, value in update_data.items():
setattr(note, field, value)
db.commit()
db.refresh(note)
return NoteDocumentResponse.from_orm(note)
@router.delete("/{note_id}")
def delete_note_document(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 문서 삭제"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 권한 확인
if note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 자식 노트들이 있는지 확인
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == note.id
).scalar()
if child_count > 0:
raise HTTPException(
status_code=400,
detail=f"Cannot delete note with {child_count} child notes"
)
db.delete(note)
db.commit()
return {"message": "Note deleted successfully"}
@router.get("/{note_id}/content")
def get_note_document_content(
note_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_sync_db)
):
"""노트 문서의 HTML 콘텐츠만 반환"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note document not found")
return note.content or ""

View File

@@ -0,0 +1,103 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from ...core.database import get_sync_db
from ..dependencies import get_current_user
from ...models.user import User
from ...models.note_highlight import NoteHighlight, NoteHighlightCreate, NoteHighlightUpdate, NoteHighlightResponse
from ...models.note_document import NoteDocument
router = APIRouter()
@router.get("/note/{note_id}/highlights", response_model=List[NoteHighlightResponse])
def get_note_highlights(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 노트의 하이라이트 목록 조회"""
# 노트 존재 확인
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 하이라이트 조회
highlights = db.query(NoteHighlight).filter(
NoteHighlight.note_id == note_id
).order_by(NoteHighlight.start_offset).all()
return [NoteHighlightResponse.from_orm(highlight) for highlight in highlights]
@router.post("/note-highlights/", response_model=NoteHighlightResponse)
def create_note_highlight(
highlight_data: NoteHighlightCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 하이라이트 생성"""
# 노트 존재 확인
note = db.query(NoteDocument).filter(NoteDocument.id == highlight_data.note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 하이라이트 생성
highlight = NoteHighlight(
note_id=highlight_data.note_id,
start_offset=highlight_data.start_offset,
end_offset=highlight_data.end_offset,
selected_text=highlight_data.selected_text,
highlight_color=highlight_data.highlight_color,
highlight_type=highlight_data.highlight_type,
created_by=current_user.email
)
db.add(highlight)
db.commit()
db.refresh(highlight)
return NoteHighlightResponse.from_orm(highlight)
@router.put("/note-highlights/{highlight_id}", response_model=NoteHighlightResponse)
def update_note_highlight(
highlight_id: str,
highlight_data: NoteHighlightUpdate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 하이라이트 수정"""
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first()
if not highlight:
raise HTTPException(status_code=404, detail="Highlight not found")
# 권한 확인
if highlight.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 업데이트
for field, value in highlight_data.dict(exclude_unset=True).items():
setattr(highlight, field, value)
db.commit()
db.refresh(highlight)
return NoteHighlightResponse.from_orm(highlight)
@router.delete("/note-highlights/{highlight_id}")
def delete_note_highlight(
highlight_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 하이라이트 삭제"""
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first()
if not highlight:
raise HTTPException(status_code=404, detail="Highlight not found")
# 권한 확인
if highlight.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
db.delete(highlight)
db.commit()
return {"message": "Highlight deleted successfully"}

View File

@@ -0,0 +1,291 @@
"""
노트 문서 링크 관련 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from typing import List, Optional
from pydantic import BaseModel
import uuid
from ...core.database import get_sync_db
from ..dependencies import get_current_user
from ...models.user import User
from ...models.note_document import NoteDocument
from ...models.document import Document
from ...models.note_link import NoteLink
router = APIRouter()
# Pydantic 모델들
class NoteLinkCreate(BaseModel):
target_note_id: Optional[str] = None
target_document_id: Optional[str] = None
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str] = None
description: Optional[str] = None
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: Optional[str] = "note"
class NoteLinkUpdate(BaseModel):
target_note_id: Optional[str] = None
target_document_id: Optional[str] = None
link_text: Optional[str] = None
description: Optional[str] = None
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: Optional[str] = None
class NoteLinkResponse(BaseModel):
id: str
source_note_id: Optional[str] = None
source_document_id: Optional[str] = None
target_note_id: Optional[str] = None
target_document_id: Optional[str] = None
target_content_type: Optional[str] = None # "document" or "note"
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str] = None
description: Optional[str] = None
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: str
created_at: str
updated_at: Optional[str] = None
# 추가 정보
target_note_title: Optional[str] = None
target_document_title: Optional[str] = None
source_note_title: Optional[str] = None
source_document_title: Optional[str] = None
class Config:
from_attributes = True
@router.get("/note-documents/{note_id}/links", response_model=List[NoteLinkResponse])
def get_note_links(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트에서 나가는 링크 목록 조회"""
# 노트 존재 확인
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 노트에서 나가는 링크들 조회
links = db.query(NoteLink).filter(
NoteLink.source_note_id == note_id
).all()
result = []
for link in links:
link_data = {
"id": str(link.id),
"source_note_id": str(link.source_note_id) if link.source_note_id else None,
"source_document_id": str(link.source_document_id) if link.source_document_id else None,
"target_note_id": str(link.target_note_id) if link.target_note_id else None,
"target_document_id": str(link.target_document_id) if link.target_document_id else None,
"selected_text": link.selected_text,
"start_offset": link.start_offset,
"end_offset": link.end_offset,
"link_text": link.link_text,
"description": link.description,
"target_text": link.target_text,
"target_start_offset": link.target_start_offset,
"target_end_offset": link.target_end_offset,
"link_type": link.link_type,
"created_at": link.created_at.isoformat() if link.created_at else None,
"updated_at": link.updated_at.isoformat() if link.updated_at else None,
}
# 대상 제목 및 타입 추가
if link.target_note_id:
target_note = db.query(NoteDocument).filter(NoteDocument.id == link.target_note_id).first()
if target_note:
link_data["target_note_title"] = target_note.title
link_data["target_content_type"] = "note"
elif link.target_document_id:
target_doc = db.query(Document).filter(Document.id == link.target_document_id).first()
if target_doc:
link_data["target_document_title"] = target_doc.title
link_data["target_content_type"] = "document"
result.append(NoteLinkResponse(**link_data))
return result
@router.get("/note-documents/{note_id}/backlinks", response_model=List[NoteLinkResponse])
def get_note_backlinks(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트로 들어오는 백링크 목록 조회"""
# 노트 존재 확인
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 노트로 들어오는 백링크들 조회
backlinks = db.query(NoteLink).filter(
NoteLink.target_note_id == note_id
).all()
result = []
for link in backlinks:
link_data = {
"id": str(link.id),
"source_note_id": str(link.source_note_id) if link.source_note_id else None,
"source_document_id": str(link.source_document_id) if link.source_document_id else None,
"target_note_id": str(link.target_note_id) if link.target_note_id else None,
"target_document_id": str(link.target_document_id) if link.target_document_id else None,
"selected_text": link.selected_text,
"start_offset": link.start_offset,
"end_offset": link.end_offset,
"link_text": link.link_text,
"description": link.description,
"target_text": link.target_text,
"target_start_offset": link.target_start_offset,
"target_end_offset": link.target_end_offset,
"link_type": link.link_type,
"created_at": link.created_at.isoformat() if link.created_at else None,
"updated_at": link.updated_at.isoformat() if link.updated_at else None,
}
# 출발지 제목 추가
if link.source_note_id:
source_note = db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first()
if source_note:
link_data["source_note_title"] = source_note.title
elif link.source_document_id:
source_doc = db.query(Document).filter(Document.id == link.source_document_id).first()
if source_doc:
link_data["source_document_title"] = source_doc.title
result.append(NoteLinkResponse(**link_data))
return result
@router.post("/note-documents/{note_id}/links", response_model=NoteLinkResponse)
def create_note_link(
note_id: str,
link_data: NoteLinkCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트에서 다른 노트/문서로의 링크 생성"""
# 출발지 노트 존재 확인
source_note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not source_note:
raise HTTPException(status_code=404, detail="Source note not found")
# 대상 확인 (노트 또는 문서 중 하나는 반드시 있어야 함)
if not link_data.target_note_id and not link_data.target_document_id:
raise HTTPException(status_code=400, detail="Either target_note_id or target_document_id is required")
if link_data.target_note_id and link_data.target_document_id:
raise HTTPException(status_code=400, detail="Cannot specify both target_note_id and target_document_id")
# 대상 존재 확인
if link_data.target_note_id:
target_note = db.query(NoteDocument).filter(NoteDocument.id == link_data.target_note_id).first()
if not target_note:
raise HTTPException(status_code=404, detail="Target note not found")
if link_data.target_document_id:
target_doc = db.query(Document).filter(Document.id == link_data.target_document_id).first()
if not target_doc:
raise HTTPException(status_code=404, detail="Target document not found")
# 링크 생성
note_link = NoteLink(
source_note_id=note_id,
target_note_id=link_data.target_note_id,
target_document_id=link_data.target_document_id,
selected_text=link_data.selected_text,
start_offset=link_data.start_offset,
end_offset=link_data.end_offset,
link_text=link_data.link_text,
description=link_data.description,
target_text=link_data.target_text,
target_start_offset=link_data.target_start_offset,
target_end_offset=link_data.target_end_offset,
link_type=link_data.link_type or "note",
created_by=current_user.id
)
db.add(note_link)
db.commit()
db.refresh(note_link)
# 응답 데이터 구성
response_data = {
"id": str(note_link.id),
"source_note_id": str(note_link.source_note_id) if note_link.source_note_id else None,
"source_document_id": str(note_link.source_document_id) if note_link.source_document_id else None,
"target_note_id": str(note_link.target_note_id) if note_link.target_note_id else None,
"target_document_id": str(note_link.target_document_id) if note_link.target_document_id else None,
"selected_text": note_link.selected_text,
"start_offset": note_link.start_offset,
"end_offset": note_link.end_offset,
"link_text": note_link.link_text,
"description": note_link.description,
"target_text": note_link.target_text,
"target_start_offset": note_link.target_start_offset,
"target_end_offset": note_link.target_end_offset,
"link_type": note_link.link_type,
"created_at": note_link.created_at.isoformat() if note_link.created_at else None,
"updated_at": note_link.updated_at.isoformat() if note_link.updated_at else None,
}
# 소스 및 타겟 타입 설정
response_data["source_content_type"] = "note" # 노트에서 출발하는 링크
if note_link.target_note_id:
target_note = db.query(NoteDocument).filter(NoteDocument.id == note_link.target_note_id).first()
if target_note:
response_data["target_note_title"] = target_note.title
response_data["target_content_type"] = "note"
elif note_link.target_document_id:
target_doc = db.query(Document).filter(Document.id == note_link.target_document_id).first()
if target_doc:
response_data["target_document_title"] = target_doc.title
response_data["target_content_type"] = "document"
return NoteLinkResponse(**response_data)
@router.delete("/note-links/{link_id}")
def delete_note_link(
link_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 링크 삭제"""
link = db.query(NoteLink).filter(NoteLink.id == link_id).first()
if not link:
raise HTTPException(status_code=404, detail="Link not found")
# 권한 확인 (링크 생성자 또는 관리자만 삭제 가능)
if link.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
db.delete(link)
db.commit()
return {"message": "Link deleted successfully"}

View File

@@ -0,0 +1,128 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, selectinload
from typing import List
from ...core.database import get_sync_db
from ..dependencies import get_current_user
from ...models.user import User
from ...models.note_note import NoteNote, NoteNoteCreate, NoteNoteUpdate, NoteNoteResponse
from ...models.note_document import NoteDocument
from ...models.note_highlight import NoteHighlight
router = APIRouter()
@router.get("/note/{note_id}/notes", response_model=List[NoteNoteResponse])
def get_note_notes(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 노트의 메모 목록 조회"""
# 노트 존재 확인
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 메모 조회
notes = db.query(NoteNote).filter(
NoteNote.note_id == note_id
).options(
selectinload(NoteNote.highlight)
).order_by(NoteNote.created_at.desc()).all()
return [NoteNoteResponse.from_orm(note) for note in notes]
@router.get("/note-highlights/{highlight_id}/notes", response_model=List[NoteNoteResponse])
def get_highlight_notes(
highlight_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 하이라이트의 메모 목록 조회"""
# 하이라이트 존재 확인
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first()
if not highlight:
raise HTTPException(status_code=404, detail="Highlight not found")
# 메모 조회
notes = db.query(NoteNote).filter(
NoteNote.highlight_id == highlight_id
).order_by(NoteNote.created_at.desc()).all()
return [NoteNoteResponse.from_orm(note) for note in notes]
@router.post("/note-notes/", response_model=NoteNoteResponse)
def create_note_note(
note_data: NoteNoteCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 메모 생성"""
# 노트 존재 확인
note = db.query(NoteDocument).filter(NoteDocument.id == note_data.note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 하이라이트 존재 확인 (선택사항)
if note_data.highlight_id:
highlight = db.query(NoteHighlight).filter(NoteHighlight.id == note_data.highlight_id).first()
if not highlight:
raise HTTPException(status_code=404, detail="Highlight not found")
# 메모 생성
note_note = NoteNote(
note_id=note_data.note_id,
highlight_id=note_data.highlight_id,
content=note_data.content,
created_by=current_user.email
)
db.add(note_note)
db.commit()
db.refresh(note_note)
return NoteNoteResponse.from_orm(note_note)
@router.put("/note-notes/{note_note_id}", response_model=NoteNoteResponse)
def update_note_note(
note_note_id: str,
note_data: NoteNoteUpdate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 메모 수정"""
note_note = db.query(NoteNote).filter(NoteNote.id == note_note_id).first()
if not note_note:
raise HTTPException(status_code=404, detail="Note not found")
# 권한 확인
if note_note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 업데이트
for field, value in note_data.dict(exclude_unset=True).items():
setattr(note_note, field, value)
db.commit()
db.refresh(note_note)
return NoteNoteResponse.from_orm(note_note)
@router.delete("/note-notes/{note_note_id}")
def delete_note_note(
note_note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 메모 삭제"""
note_note = db.query(NoteNote).filter(NoteNote.id == note_note_id).first()
if not note_note:
raise HTTPException(status_code=404, detail="Note not found")
# 권한 확인
if note_note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
db.delete(note_note)
db.commit()
return {"message": "Note deleted successfully"}

View File

@@ -0,0 +1,270 @@
"""
노트북 (Notebook) 관리 API
용어 정의:
- 노트북 (Notebook): 노트 문서들을 그룹화하는 폴더
- 노트 (Note Document): 독립적인 HTML 기반 문서 작성
- 메모 (Memo): 하이라이트에 달리는 짧은 코멘트 (별도 API)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, asc, select
from typing import List, Optional
from ...core.database import get_sync_db
from ...models.notebook import (
Notebook,
NotebookCreate,
NotebookUpdate,
NotebookResponse,
NotebookListItem,
NotebookStats
)
from ...models.note_document import NoteDocument
from ...models.user import User
from ..dependencies import get_current_user
router = APIRouter()
@router.get("/", response_model=List[NotebookListItem])
def get_notebooks(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: Optional[str] = Query(None),
active_only: bool = Query(True),
sort_by: str = Query("updated_at", regex="^(title|created_at|updated_at|sort_order)$"),
order: str = Query("desc", regex="^(asc|desc)$"),
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트북 목록 조회"""
query = db.query(Notebook)
# 필터링
if search:
search_term = f"%{search}%"
query = query.filter(
(Notebook.title.ilike(search_term)) |
(Notebook.description.ilike(search_term))
)
if active_only:
query = query.filter(Notebook.is_active == True)
# 정렬
if sort_by == 'title':
query = query.order_by(asc(Notebook.title) if order == 'asc' else desc(Notebook.title))
elif sort_by == 'created_at':
query = query.order_by(asc(Notebook.created_at) if order == 'asc' else desc(Notebook.created_at))
elif sort_by == 'sort_order':
query = query.order_by(asc(Notebook.sort_order) if order == 'asc' else desc(Notebook.sort_order))
else:
query = query.order_by(desc(Notebook.updated_at))
# 페이지네이션
notebooks = query.offset(skip).limit(limit).all()
# 노트 개수 계산
result = []
for notebook in notebooks:
note_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.notebook_id == notebook.id
).scalar()
notebook_item = NotebookListItem.from_orm(notebook, note_count)
result.append(notebook_item)
return result
@router.get("/stats", response_model=NotebookStats)
def get_notebook_stats(
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트북 통계 정보"""
total_notebooks = db.query(func.count(Notebook.id)).scalar()
active_notebooks = db.query(func.count(Notebook.id)).filter(
Notebook.is_active == True
).scalar()
total_notes = db.query(func.count(NoteDocument.id)).scalar()
notes_without_notebook = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.notebook_id.is_(None)
).scalar()
return NotebookStats(
total_notebooks=total_notebooks,
active_notebooks=active_notebooks,
total_notes=total_notes,
notes_without_notebook=notes_without_notebook
)
@router.get("/{notebook_id}", response_model=NotebookResponse)
def get_notebook(
notebook_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 노트북 조회"""
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# 노트 개수 계산
note_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.notebook_id == notebook.id
).scalar()
return NotebookResponse.from_orm(notebook, note_count)
@router.post("/", response_model=NotebookResponse)
def create_notebook(
notebook_data: NotebookCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""새 노트북 생성"""
notebook = Notebook(
title=notebook_data.title,
description=notebook_data.description,
color=notebook_data.color,
icon=notebook_data.icon,
is_active=notebook_data.is_active,
sort_order=notebook_data.sort_order,
created_by=current_user.email
)
db.add(notebook)
db.commit()
db.refresh(notebook)
return NotebookResponse.from_orm(notebook, 0)
@router.put("/{notebook_id}", response_model=NotebookResponse)
def update_notebook(
notebook_id: str,
notebook_data: NotebookUpdate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트북 업데이트"""
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# 업데이트할 필드만 적용
update_data = notebook_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(notebook, field, value)
db.commit()
db.refresh(notebook)
# 노트 개수 계산
note_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.notebook_id == notebook.id
).scalar()
return NotebookResponse.from_orm(notebook, note_count)
@router.delete("/{notebook_id}")
def delete_notebook(
notebook_id: str,
force: bool = Query(False, description="강제 삭제 (노트가 있어도 삭제)"),
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트북 삭제"""
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# 노트북에 포함된 노트 확인
note_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.notebook_id == notebook.id
).scalar()
if note_count > 0 and not force:
raise HTTPException(
status_code=400,
detail=f"Cannot delete notebook with {note_count} notes. Use force=true to delete anyway."
)
if force and note_count > 0:
# 노트들의 notebook_id를 NULL로 설정 (기본 노트북으로 이동)
db.query(NoteDocument).filter(
NoteDocument.notebook_id == notebook.id
).update({NoteDocument.notebook_id: None})
db.delete(notebook)
db.commit()
return {"message": "Notebook deleted successfully"}
@router.get("/{notebook_id}/notes")
def get_notebook_notes(
notebook_id: str,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트북에 포함된 노트들 조회"""
# 노트북 존재 확인
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# 노트들 조회
notes = db.query(NoteDocument).filter(
NoteDocument.notebook_id == notebook_id
).order_by(desc(NoteDocument.updated_at)).offset(skip).limit(limit).all()
return notes
@router.post("/{notebook_id}/notes/{note_id}")
def add_note_to_notebook(
notebook_id: str,
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트를 노트북에 추가"""
# 노트북과 노트 존재 확인
notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first()
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 노트를 노트북에 할당
note.notebook_id = notebook_id
db.commit()
return {"message": "Note added to notebook successfully"}
@router.delete("/{notebook_id}/notes/{note_id}")
def remove_note_from_notebook(
notebook_id: str,
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트를 노트북에서 제거"""
note = db.query(NoteDocument).filter(
NoteDocument.id == note_id,
NoteDocument.notebook_id == notebook_id
).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found in this notebook")
# 노트북에서 제거 (기본 노트북으로 이동)
note.notebook_id = None
db.commit()
return {"message": "Note removed from notebook successfully"}

View File

@@ -0,0 +1,532 @@
"""
노트 문서 (Note Document) 관리 API
용어 정의:
- 노트 (Note Document): 독립적인 HTML 기반 문서 작성
- 노트북 (Notebook): 노트들을 그룹화하는 폴더
- 메모 (Memo): 하이라이트에 달리는 짧은 코멘트 (별도 API - highlights.py)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import func, desc, asc, select
from typing import List, Optional
# import markdown # 임시로 비활성화
import re
from datetime import datetime, timedelta
from ...core.database import get_sync_db
from ...models.note_document import (
NoteDocument,
NoteDocumentCreate,
NoteDocumentUpdate,
NoteDocumentResponse,
NoteDocumentListItem,
NoteStats
)
from ...models.user import User
from ..dependencies import get_current_user
router = APIRouter()
# === 하이라이트 메모 (Highlight Memo) API ===
# 용어 정의: 하이라이트에 달리는 짧은 코멘트
@router.post("/")
def create_note(
note_data: dict,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""하이라이트 메모 생성"""
from ...models.note import Note
from ...models.highlight import Highlight
# 하이라이트 소유권 확인
highlight = db.query(Highlight).filter(
Highlight.id == note_data.get('highlight_id'),
Highlight.user_id == current_user.id
).first()
if not highlight:
raise HTTPException(status_code=404, detail="하이라이트를 찾을 수 없습니다")
# 메모 생성
note = Note(
highlight_id=note_data.get('highlight_id'),
content=note_data.get('content', ''),
is_private=note_data.get('is_private', False),
tags=note_data.get('tags', [])
)
db.add(note)
db.commit()
db.refresh(note)
return note
@router.put("/{note_id}")
def update_note(
note_id: str,
note_data: dict,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""하이라이트 메모 업데이트"""
from ...models.note import Note
from ...models.highlight import Highlight
# 메모 존재 및 소유권 확인
note = db.query(Note).join(Highlight).filter(
Note.id == note_id,
Highlight.user_id == current_user.id
).first()
if not note:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
# 메모 업데이트
if 'content' in note_data:
note.content = note_data['content']
if 'tags' in note_data:
note.tags = note_data['tags']
note.updated_at = datetime.utcnow()
db.commit()
db.refresh(note)
return note
@router.delete("/{note_id}")
def delete_highlight_note(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""하이라이트 메모 삭제"""
from ...models.note import Note
from ...models.highlight import Highlight
note = db.query(Note).join(Highlight).filter(
Note.id == note_id,
Highlight.user_id == current_user.id
).first()
if not note:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
db.delete(note)
db.commit()
return {"message": "메모가 삭제되었습니다"}
@router.get("/document/{document_id}")
async def get_document_notes(
document_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 문서의 모든 하이라이트 메모 조회"""
from ...models.note import Note
from ...models.highlight import Highlight
notes = db.query(Note).join(Highlight).filter(
Highlight.document_id == document_id,
Highlight.user_id == current_user.id
).options(
selectinload(Note.highlight)
).all()
return notes
def clean_html_content(content: str) -> str:
"""HTML 내용 정리 및 검증"""
if not content:
return ""
# 기본적인 HTML 정리 (나중에 더 정교하게 할 수 있음)
return content.strip()
def calculate_reading_time(content: str) -> int:
"""읽기 시간 계산 (분 단위)"""
if not content:
return 0
# 단어 수 계산 (한글, 영문 모두 고려)
korean_chars = len(re.findall(r'[가-힣]', content))
english_words = len(re.findall(r'\b[a-zA-Z]+\b', content))
# 한글: 분당 500자, 영문: 분당 200단어 기준
korean_time = korean_chars / 500
english_time = english_words / 200
total_minutes = max(1, int(korean_time + english_time))
return total_minutes
def calculate_word_count(content: str) -> int:
"""단어/글자 수 계산"""
if not content:
return 0
korean_chars = len(re.findall(r'[가-힣]', content))
english_words = len(re.findall(r'\b[a-zA-Z]+\b', content))
return korean_chars + english_words
@router.get("/")
def get_notes(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
note_type: Optional[str] = Query(None),
tags: Optional[str] = Query(None), # 쉼표로 구분된 태그
search: Optional[str] = Query(None),
published_only: bool = Query(False),
parent_id: Optional[str] = Query(None),
notebook_id: Optional[str] = Query(None), # 노트북 필터
document_id: Optional[str] = Query(None), # 하이라이트 메모 조회용
note_document_id: Optional[str] = Query(None), # 노트 문서의 하이라이트 메모 조회용
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 목록 조회 또는 하이라이트 메모 조회"""
# 하이라이트 메모 조회 요청인 경우
if document_id or note_document_id:
from ...models.note import Note
from ...models.highlight import Highlight
if document_id:
# 일반 문서의 하이라이트 메모 조회
notes = db.query(Note).join(Highlight).filter(
Highlight.document_id == document_id,
Highlight.user_id == current_user.id
).options(
selectinload(Note.highlight)
).all()
else:
# 노트 문서의 하이라이트 메모 조회 (note_document_id)
# 노트 하이라이트 모델이 있다면 사용, 없다면 빈 리스트 반환
notes = []
return notes
# 일반 노트 문서 목록 조회
# 동기 SQLAlchemy 스타일
query = db.query(NoteDocument)
# 필터링
if note_type:
query = query.filter(NoteDocument.note_type == note_type)
if tags:
tag_list = [tag.strip() for tag in tags.split(',')]
query = query.filter(NoteDocument.tags.overlap(tag_list))
if search:
search_term = f"%{search}%"
query = query.filter(
(NoteDocument.title.ilike(search_term)) |
(NoteDocument.content.ilike(search_term))
)
if published_only:
query = query.filter(NoteDocument.is_published == True)
if notebook_id:
if notebook_id == 'null':
# 미분류 노트 (notebook_id가 None인 것들)
query = query.filter(NoteDocument.notebook_id.is_(None))
else:
query = query.filter(NoteDocument.notebook_id == notebook_id)
if parent_id:
query = query.filter(NoteDocument.parent_note_id == parent_id)
else:
# 최상위 노트만 (parent_id가 None인 것들)
query = query.filter(NoteDocument.parent_note_id.is_(None))
# 정렬 및 페이징
query = query.order_by(desc(NoteDocument.updated_at))
notes = query.offset(skip).limit(limit).all()
# 자식 노트 개수 계산
result = []
for note in notes:
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == note.id
).scalar()
note_item = NoteDocumentListItem.from_orm(note, child_count)
result.append(note_item)
return result
@router.get("/stats", response_model=NoteStats)
def get_note_stats(
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 통계 정보"""
total_notes = db.query(func.count(NoteDocument.id)).scalar()
published_notes = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.is_published == True
).scalar()
draft_notes = total_notes - published_notes
# 노트 타입별 통계
type_stats = db.query(
NoteDocument.note_type,
func.count(NoteDocument.id)
).group_by(NoteDocument.note_type).all()
note_types = {note_type: count for note_type, count in type_stats}
# 총 단어 수와 읽기 시간
totals = db.query(
func.sum(NoteDocument.word_count),
func.sum(NoteDocument.reading_time)
).first()
total_words = totals[0] or 0
total_reading_time = totals[1] or 0
# 최근 노트 (5개)
recent_notes_query = db.query(NoteDocument).order_by(
desc(NoteDocument.updated_at)
).limit(5)
recent_notes = []
for note in recent_notes_query.all():
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == note.id
).scalar()
note_item = NoteDocumentListItem.from_orm(note, child_count)
recent_notes.append(note_item)
return NoteStats(
total_notes=total_notes,
published_notes=published_notes,
draft_notes=draft_notes,
note_types=note_types,
total_words=total_words,
total_reading_time=total_reading_time,
recent_notes=recent_notes
)
@router.get("/{note_id}", response_model=NoteDocumentResponse)
def get_note(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""특정 노트 조회"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
return NoteDocumentResponse.from_orm(note)
@router.post("/", response_model=NoteDocumentResponse)
def create_note(
note_data: NoteDocumentCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""새 노트 생성"""
# HTML 내용 정리
cleaned_content = clean_html_content(note_data.content or "")
# 통계 계산
word_count = calculate_word_count(note_data.content or "")
reading_time = calculate_reading_time(note_data.content or "")
note = NoteDocument(
title=note_data.title,
content=cleaned_content,
note_type=note_data.note_type,
tags=note_data.tags,
is_published=note_data.is_published,
parent_note_id=note_data.parent_note_id,
sort_order=note_data.sort_order,
word_count=word_count,
reading_time=reading_time,
created_by=current_user.email
)
db.add(note)
db.commit()
db.refresh(note)
return NoteDocumentResponse.from_orm(note)
@router.put("/{note_id}", response_model=NoteDocumentResponse)
def update_note(
note_id: str,
note_data: NoteDocumentUpdate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 수정"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 수정 권한 확인 (작성자만 수정 가능)
if note.created_by != current_user.username and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 필드 업데이트
update_data = note_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(note, field, value)
# 내용이 변경된 경우 통계 재계산
if 'content' in update_data:
note.content = clean_html_content(note.content or "")
note.word_count = calculate_word_count(note.content or "")
note.reading_time = calculate_reading_time(note.content or "")
db.commit()
db.refresh(note)
return NoteDocumentResponse.from_orm(note)
@router.delete("/{note_id}")
def delete_note(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 삭제"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 삭제 권한 확인
if note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 자식 노트들의 parent_note_id를 NULL로 설정
db.query(NoteDocument).filter(
NoteDocument.parent_note_id == note_id
).update({"parent_note_id": None})
db.delete(note)
db.commit()
return {"message": "Note deleted successfully"}
@router.get("/{note_id}/children", response_model=List[NoteDocumentListItem])
async def get_note_children(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트의 자식 노트들 조회"""
children = db.query(NoteDocument).filter(
NoteDocument.parent_note_id == note_id
).order_by(asc(NoteDocument.sort_order), desc(NoteDocument.updated_at)).all()
result = []
for child in children:
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == child.id
).scalar()
child_item = NoteDocumentListItem.from_orm(child)
child_item.child_count = child_count
result.append(child_item)
return result
@router.get("/{note_id}/export/html")
async def export_note_html(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트를 HTML 파일로 내보내기"""
from fastapi.responses import Response
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# HTML 템플릿 생성
html_template = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{note.title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }}
h1, h2, h3 {{ color: #333; }}
code {{ background: #f4f4f4; padding: 2px 4px; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 20px; color: #666; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #f2f2f2; }}
.meta {{ color: #666; font-size: 0.9em; margin-bottom: 20px; }}
</style>
</head>
<body>
<div class="meta">
<strong>제목:</strong> {note.title}<br>
<strong>타입:</strong> {note.note_type}<br>
<strong>작성자:</strong> {note.created_by}<br>
<strong>작성일:</strong> {note.created_at.strftime('%Y-%m-%d %H:%M')}<br>
<strong>태그:</strong> {', '.join(note.tags) if note.tags else '없음'}
</div>
<hr>
{note.content or ''}
</body>
</html>"""
filename = f"{note.title.replace(' ', '_')}.html"
return Response(
content=html_template,
media_type="text/html",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.get("/{note_id}/export/markdown")
async def export_note_markdown(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트를 마크다운 파일로 내보내기"""
from fastapi.responses import Response
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# 메타데이터 포함한 마크다운
markdown_content = f"""---
title: {note.title}
type: {note.note_type}
author: {note.created_by}
created: {note.created_at.strftime('%Y-%m-%d %H:%M')}
tags: [{', '.join(note.tags) if note.tags else ''}]
---
# {note.title}
{note.content or ''}
"""
filename = f"{note.title.replace(' ', '_')}.md"
return Response(
content=markdown_content,
media_type="text/plain",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)

View File

@@ -0,0 +1,671 @@
"""
검색 API 라우터
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, and_, text
from sqlalchemy.orm import joinedload, selectinload
from typing import List, Optional, Dict, Any
from datetime import datetime
from ...core.database import get_db
from ...models.user import User
from ...models.document import Document, Tag
from ...models.highlight import Highlight
from ...models.note import Note
from ...models.memo_tree import MemoTree, MemoNode
from ...models.note_document import NoteDocument
from ..dependencies import get_current_active_user
from pydantic import BaseModel
class SearchResult(BaseModel):
"""검색 결과"""
type: str # "document", "note", "highlight"
id: str
title: str
content: str
document_id: str
document_title: str
created_at: datetime
relevance_score: float = 0.0
highlight_info: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
class SearchResponse(BaseModel):
"""검색 응답"""
query: str
total_results: int
results: List[SearchResult]
facets: Dict[str, List[Dict[str, Any]]] = {}
router = APIRouter()
@router.get("/", response_model=SearchResponse)
async def search_all(
q: str = Query(..., description="검색어"),
type_filter: Optional[str] = Query(None, description="검색 타입 필터: document, note, memo, highlight"),
document_id: Optional[str] = Query(None, description="특정 문서 내 검색"),
tag: Optional[str] = Query(None, description="태그 필터"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""통합 검색 (문서 + 메모 + 하이라이트)"""
results = []
# 1. 문서 검색
if not type_filter or type_filter == "document":
document_results = await search_documents(q, document_id, tag, current_user, db)
results.extend(document_results)
# 2. 노트 문서 검색
if not type_filter or type_filter == "note":
note_results = await search_note_documents(q, current_user, db)
results.extend(note_results)
# 3. 메모 트리 노드 검색
if not type_filter or type_filter == "memo":
memo_results = await search_memo_nodes(q, current_user, db)
results.extend(memo_results)
# 4. 기존 메모 검색 (하위 호환성)
if not type_filter or type_filter == "note":
old_note_results = await search_notes(q, document_id, tag, current_user, db)
results.extend(old_note_results)
# 5. 하이라이트 검색
if not type_filter or type_filter == "highlight":
highlight_results = await search_highlights(q, document_id, current_user, db)
results.extend(highlight_results)
# 6. 하이라이트 메모 검색
if not type_filter or type_filter == "highlight_note":
highlight_note_results = await search_highlight_notes(q, document_id, current_user, db)
results.extend(highlight_note_results)
# 7. 문서 본문 검색 (OCR 데이터)
if not type_filter or type_filter == "document_content":
content_results = await search_document_content(q, document_id, current_user, db)
results.extend(content_results)
# 관련성 점수로 정렬
results.sort(key=lambda x: x.relevance_score, reverse=True)
# 페이지네이션
total_results = len(results)
paginated_results = results[skip:skip + limit]
# 패싯 정보 생성
facets = await generate_search_facets(results, current_user, db)
return SearchResponse(
query=q,
total_results=total_results,
results=paginated_results,
facets=facets
)
async def search_documents(
query: str,
document_id: Optional[str],
tag: Optional[str],
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""문서 검색"""
query_obj = select(Document).options(
selectinload(Document.uploader),
selectinload(Document.tags)
)
# 권한 필터링
if not current_user.is_admin:
query_obj = query_obj.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
# 특정 문서 필터
if document_id:
query_obj = query_obj.where(Document.id == document_id)
# 태그 필터
if tag:
query_obj = query_obj.join(Document.tags).where(Tag.name == tag)
# 텍스트 검색
search_condition = or_(
Document.title.ilike(f"%{query}%"),
Document.description.ilike(f"%{query}%")
)
query_obj = query_obj.where(search_condition)
result = await db.execute(query_obj)
documents = result.scalars().all()
search_results = []
for doc in documents:
# 관련성 점수 계산 (제목 매치가 더 높은 점수)
score = 0.0
if query.lower() in doc.title.lower():
score += 2.0
if doc.description and query.lower() in doc.description.lower():
score += 1.0
search_results.append(SearchResult(
type="document",
id=str(doc.id),
title=doc.title,
content=doc.description or "",
document_id=str(doc.id),
document_title=doc.title,
created_at=doc.created_at,
relevance_score=score
))
return search_results
async def search_notes(
query: str,
document_id: Optional[str],
tag: Optional[str],
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""메모 검색"""
query_obj = (
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.join(Highlight)
.where(Highlight.user_id == current_user.id)
)
# 특정 문서 필터
if document_id:
query_obj = query_obj.where(Highlight.document_id == document_id)
# 태그 필터
if tag:
query_obj = query_obj.where(Note.tags.contains([tag]))
# 텍스트 검색 (메모 내용 + 하이라이트된 텍스트)
search_condition = or_(
Note.content.ilike(f"%{query}%"),
Highlight.selected_text.ilike(f"%{query}%")
)
query_obj = query_obj.where(search_condition)
result = await db.execute(query_obj)
notes = result.scalars().all()
search_results = []
for note in notes:
# 관련성 점수 계산
score = 0.0
if query.lower() in note.content.lower():
score += 2.0
if query.lower() in note.highlight.selected_text.lower():
score += 1.5
search_results.append(SearchResult(
type="note",
id=str(note.id),
title=f"메모: {note.highlight.selected_text[:50]}...",
content=note.content,
document_id=str(note.highlight.document.id),
document_title=note.highlight.document.title,
created_at=note.created_at,
relevance_score=score,
highlight_info={
"highlight_id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
))
return search_results
async def search_highlights(
query: str,
document_id: Optional[str],
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""하이라이트 검색"""
query_obj = (
select(Highlight)
.options(joinedload(Highlight.document))
.where(Highlight.user_id == current_user.id)
)
# 특정 문서 필터
if document_id:
query_obj = query_obj.where(Highlight.document_id == document_id)
# 텍스트 검색
query_obj = query_obj.where(Highlight.selected_text.ilike(f"%{query}%"))
result = await db.execute(query_obj)
highlights = result.scalars().all()
search_results = []
for highlight in highlights:
# 관련성 점수 계산
score = 1.0 if query.lower() in highlight.selected_text.lower() else 0.5
search_results.append(SearchResult(
type="highlight",
id=str(highlight.id),
title=f"하이라이트: {highlight.selected_text[:50]}...",
content=highlight.selected_text,
document_id=str(highlight.document.id),
document_title=highlight.document.title,
created_at=highlight.created_at,
relevance_score=score,
highlight_info={
"highlight_id": str(highlight.id),
"selected_text": highlight.selected_text,
"start_offset": highlight.start_offset,
"end_offset": highlight.end_offset,
"highlight_color": highlight.highlight_color
}
))
return search_results
async def generate_search_facets(
results: List[SearchResult],
current_user: User,
db: AsyncSession
) -> Dict[str, List[Dict[str, Any]]]:
"""검색 결과 패싯 생성"""
facets = {}
# 타입별 개수
type_counts = {}
for result in results:
type_counts[result.type] = type_counts.get(result.type, 0) + 1
facets["types"] = [
{"name": type_name, "count": count}
for type_name, count in type_counts.items()
]
# 문서별 개수
document_counts = {}
for result in results:
doc_title = result.document_title
document_counts[doc_title] = document_counts.get(doc_title, 0) + 1
facets["documents"] = [
{"name": doc_title, "count": count}
for doc_title, count in sorted(document_counts.items(), key=lambda x: x[1], reverse=True)[:10]
]
return facets
@router.get("/suggestions")
async def get_search_suggestions(
q: str = Query(..., min_length=2, description="검색어 (최소 2글자)"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""검색어 자동완성 제안"""
suggestions = []
# 문서 제목에서 제안
doc_result = await db.execute(
select(Document.title)
.where(
and_(
Document.title.ilike(f"%{q}%"),
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
) if not current_user.is_admin else text("true")
)
)
.limit(5)
)
doc_titles = doc_result.scalars().all()
suggestions.extend([{"text": title, "type": "document"} for title in doc_titles])
# 태그에서 제안
tag_result = await db.execute(
select(Tag.name)
.where(Tag.name.ilike(f"%{q}%"))
.limit(5)
)
tag_names = tag_result.scalars().all()
suggestions.extend([{"text": name, "type": "tag"} for name in tag_names])
# 메모 태그에서 제안
note_result = await db.execute(
select(Note.tags)
.join(Highlight)
.where(Highlight.user_id == current_user.id)
)
notes = note_result.scalars().all()
note_tags = set()
for note in notes:
if note and isinstance(note, list):
for tag in note:
if q.lower() in tag.lower():
note_tags.add(tag)
suggestions.extend([{"text": tag, "type": "note_tag"} for tag in list(note_tags)[:5]])
return {"suggestions": suggestions[:10]}
async def search_highlight_notes(
query: str,
document_id: Optional[str],
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""하이라이트 메모 내용 검색"""
query_obj = select(Note).options(
selectinload(Note.highlight).selectinload(Highlight.document)
)
# 하이라이트가 있는 노트만
query_obj = query_obj.where(Note.highlight_id.isnot(None))
# Highlight와 조인 (권한 및 문서 필터링을 위해)
query_obj = query_obj.join(Highlight)
# 권한 필터링 - 사용자의 노트만
query_obj = query_obj.where(Highlight.user_id == current_user.id)
# 특정 문서 필터
if document_id:
query_obj = query_obj.where(Highlight.document_id == document_id)
# 메모 내용에서 검색
query_obj = query_obj.where(Note.content.ilike(f"%{query}%"))
result = await db.execute(query_obj)
notes = result.scalars().all()
search_results = []
for note in notes:
if not note.highlight or not note.highlight.document:
continue
# 관련성 점수 계산
score = 1.5 # 메모 내용 매치는 높은 점수
content_lower = (note.content or "").lower()
if query.lower() in content_lower:
score += 2.0
search_results.append(SearchResult(
type="highlight_note",
id=str(note.id),
title=f"하이라이트 메모: {note.highlight.selected_text[:30]}...",
content=note.content or "",
document_id=str(note.highlight.document.id),
document_title=note.highlight.document.title,
created_at=note.created_at,
relevance_score=score,
highlight_info={
"highlight_id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset,
"note_content": note.content
}
))
return search_results
async def search_note_documents(
query: str,
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""노트 문서 검색"""
query_obj = select(NoteDocument).where(
or_(
NoteDocument.title.ilike(f"%{query}%"),
NoteDocument.content.ilike(f"%{query}%")
)
)
# 권한 필터링 - 사용자의 노트만
query_obj = query_obj.where(NoteDocument.created_by == current_user.email)
result = await db.execute(query_obj)
notes = result.scalars().all()
search_results = []
for note in notes:
# 관련성 점수 계산
score = 1.0
if query.lower() in note.title.lower():
score += 2.0
if note.content and query.lower() in note.content.lower():
score += 1.0
search_results.append(SearchResult(
type="note",
id=str(note.id),
title=note.title,
content=note.content or "",
document_id=str(note.id), # 노트 자체가 문서
document_title=note.title,
created_at=note.created_at,
relevance_score=score
))
return search_results
async def search_memo_nodes(
query: str,
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""메모 트리 노드 검색"""
query_obj = select(MemoNode).options(
selectinload(MemoNode.tree)
).where(
or_(
MemoNode.title.ilike(f"%{query}%"),
MemoNode.content.ilike(f"%{query}%")
)
)
# 권한 필터링 - 사용자의 트리에 속한 노드만
query_obj = query_obj.join(MemoTree).where(MemoTree.user_id == current_user.id)
result = await db.execute(query_obj)
nodes = result.scalars().all()
search_results = []
for node in nodes:
# 관련성 점수 계산
score = 1.0
if query.lower() in node.title.lower():
score += 2.0
if node.content and query.lower() in node.content.lower():
score += 1.0
search_results.append(SearchResult(
type="memo",
id=str(node.id),
title=node.title,
content=node.content or "",
document_id=str(node.tree.id), # 트리 ID를 문서 ID로 사용
document_title=f"📚 {node.tree.title}",
created_at=node.created_at,
relevance_score=score
))
return search_results
async def search_document_content(
query: str,
document_id: Optional[str],
current_user: User,
db: AsyncSession
) -> List[SearchResult]:
"""문서 본문 내용 검색 (OCR 데이터 포함)"""
# 문서 권한 확인
doc_query = select(Document)
if not current_user.is_admin:
doc_query = doc_query.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
if document_id:
doc_query = doc_query.where(Document.id == document_id)
result = await db.execute(doc_query)
documents = result.scalars().all()
search_results = []
for doc in documents:
text_content = ""
file_type = ""
# HTML 파일에서 텍스트 검색 (PDF OCR 결과 또는 서적 HTML)
if doc.html_path:
try:
import os
from bs4 import BeautifulSoup
# 절대 경로 처리
if doc.html_path.startswith('/'):
html_file_path = doc.html_path
else:
html_file_path = os.path.join("/app", doc.html_path)
if os.path.exists(html_file_path):
with open(html_file_path, 'r', encoding='utf-8') as f:
html_content = f.read()
# HTML에서 텍스트 추출
soup = BeautifulSoup(html_content, 'html.parser')
text_content = soup.get_text()
# PDF인지 서적인지 구분
if doc.pdf_path:
file_type = "PDF"
else:
file_type = "HTML"
except Exception as e:
print(f"HTML 파일 읽기 오류 ({doc.html_path}): {e}")
continue
# PDF 파일 직접 텍스트 추출 (HTML이 없는 경우)
elif doc.pdf_path:
try:
import os
import PyPDF2
# 절대 경로 처리
if doc.pdf_path.startswith('/'):
pdf_file_path = doc.pdf_path
else:
pdf_file_path = os.path.join("/app", doc.pdf_path)
if os.path.exists(pdf_file_path):
with open(pdf_file_path, 'rb') as f:
pdf_reader = PyPDF2.PdfReader(f)
text_pages = []
# 모든 페이지에서 텍스트 추출
for page_num in range(len(pdf_reader.pages)):
page = pdf_reader.pages[page_num]
page_text = page.extract_text()
if page_text.strip():
text_pages.append(f"[페이지 {page_num + 1}]\n{page_text}")
text_content = "\n\n".join(text_pages)
file_type = "PDF (직접추출)"
except Exception as e:
print(f"PDF 파일 읽기 오류 ({doc.pdf_path}): {e}")
continue
# 검색어가 포함된 경우
if text_content and query.lower() in text_content.lower():
# 검색어 주변 컨텍스트 추출
context = extract_search_context(text_content, query, context_length=300)
# 관련성 점수 계산
score = 2.0 # 본문 매치는 높은 점수
# 검색어 매치 횟수로 점수 조정
match_count = text_content.lower().count(query.lower())
score += min(match_count * 0.1, 1.0) # 최대 1점 추가
search_results.append(SearchResult(
type="document_content",
id=str(doc.id),
title=f"📄 {doc.title} ({file_type} 본문)",
content=context,
document_id=str(doc.id),
document_title=doc.title,
created_at=doc.created_at,
relevance_score=score,
highlight_info={
"file_type": file_type,
"match_count": match_count,
"has_pdf": bool(doc.pdf_path),
"has_html": bool(doc.html_path)
}
))
return search_results
def extract_search_context(text: str, query: str, context_length: int = 200) -> str:
"""검색어 주변 컨텍스트 추출"""
text_lower = text.lower()
query_lower = query.lower()
# 첫 번째 매치 위치 찾기
match_pos = text_lower.find(query_lower)
if match_pos == -1:
return text[:context_length] + "..."
# 컨텍스트 시작/끝 위치 계산
start = max(0, match_pos - context_length // 2)
end = min(len(text), match_pos + len(query) + context_length // 2)
context = text[start:end]
# 앞뒤에 ... 추가
if start > 0:
context = "..." + context
if end < len(text):
context = context + "..."
return context

View File

@@ -0,0 +1,104 @@
"""
시스템 초기 설정 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from pydantic import BaseModel, EmailStr
from typing import Optional
from ...core.database import get_db
from ...core.security import get_password_hash
from ...models.user import User
router = APIRouter()
class InitialSetupRequest(BaseModel):
"""초기 설정 요청"""
admin_email: EmailStr
admin_password: str
admin_full_name: Optional[str] = None
class SetupStatusResponse(BaseModel):
"""설정 상태 응답"""
is_setup_required: bool
has_admin_user: bool
total_users: int
@router.get("/status", response_model=SetupStatusResponse)
async def get_setup_status(db: AsyncSession = Depends(get_db)):
"""시스템 설정 상태 확인"""
# 전체 사용자 수 조회
total_users_result = await db.execute(select(func.count(User.id)))
total_users = total_users_result.scalar()
# 관리자 사용자 존재 여부 확인
admin_result = await db.execute(
select(User).where(User.role == "root")
)
has_admin_user = admin_result.scalar_one_or_none() is not None
return SetupStatusResponse(
is_setup_required=total_users == 0 or not has_admin_user,
has_admin_user=has_admin_user,
total_users=total_users
)
@router.post("/initialize")
async def initialize_system(
setup_data: InitialSetupRequest,
db: AsyncSession = Depends(get_db)
):
"""시스템 초기 설정 (root 계정 생성)"""
# 이미 설정된 시스템인지 확인
existing_admin = await db.execute(
select(User).where(User.role == "root")
)
if existing_admin.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="System is already initialized"
)
# 이메일 중복 확인
existing_user = await db.execute(
select(User).where(User.email == setup_data.admin_email)
)
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Root 관리자 계정 생성
hashed_password = get_password_hash(setup_data.admin_password)
admin_user = User(
email=setup_data.admin_email,
hashed_password=hashed_password,
full_name=setup_data.admin_full_name or "시스템 관리자",
is_active=True,
is_admin=True,
role="root",
can_manage_books=True,
can_manage_notes=True,
can_manage_novels=True
)
db.add(admin_user)
await db.commit()
await db.refresh(admin_user)
return {
"message": "System initialized successfully",
"admin_user": {
"id": str(admin_user.id),
"email": admin_user.email,
"full_name": admin_user.full_name,
"role": admin_user.role
}
}

View File

@@ -0,0 +1,663 @@
"""
할일관리 시스템 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import datetime, timedelta
from uuid import UUID
from ...core.database import get_db
from ...models.user import User
from ...models.todo import TodoItem, TodoComment
from ...schemas.todo import (
TodoItemCreate, TodoItemSchedule, TodoItemUpdate, TodoItemDelay, TodoItemSplit,
TodoItemResponse, TodoItemWithComments, TodoCommentCreate, TodoCommentUpdate,
TodoCommentResponse, TodoStats, TodoDashboard
)
from ..dependencies import get_current_active_user
router = APIRouter(prefix="/todos", tags=["todos"])
# ============================================================================
# 할일 아이템 관리
# ============================================================================
@router.post("/", response_model=TodoItemResponse)
async def create_todo_item(
todo_data: TodoItemCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새 할일 생성 (draft 상태)"""
try:
new_todo = TodoItem(
user_id=current_user.id,
content=todo_data.content,
status="draft"
)
db.add(new_todo)
await db.commit()
await db.refresh(new_todo)
# 응답 데이터 구성
response_data = TodoItemResponse(
id=new_todo.id,
user_id=new_todo.user_id,
content=new_todo.content,
status=new_todo.status,
created_at=new_todo.created_at,
start_date=new_todo.start_date,
estimated_minutes=new_todo.estimated_minutes,
completed_at=new_todo.completed_at,
delayed_until=new_todo.delayed_until,
parent_id=new_todo.parent_id,
split_order=new_todo.split_order,
comment_count=0
)
return response_data
except Exception as e:
await db.rollback()
print(f"ERROR in create_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create todo item: {str(e)}"
)
@router.post("/{todo_id}/schedule", response_model=TodoItemResponse)
async def schedule_todo_item(
todo_id: UUID,
schedule_data: TodoItemSchedule,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 일정 설정 (draft -> scheduled)"""
try:
# 할일 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "draft"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not in draft status"
)
# 2시간 이상인 경우 분할 제안
if schedule_data.estimated_minutes > 120:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tasks longer than 2 hours should be split into smaller tasks"
)
# 일정 설정
todo_item.start_date = schedule_data.start_date
todo_item.estimated_minutes = schedule_data.estimated_minutes
todo_item.status = "scheduled"
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in schedule_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to schedule todo item: {str(e)}"
)
@router.post("/{todo_id}/split", response_model=List[TodoItemResponse])
async def split_todo_item(
todo_id: UUID,
split_data: TodoItemSplit,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 분할"""
try:
# 원본 할일 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "draft"
)
)
)
original_todo = result.scalar_one_or_none()
if not original_todo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not in draft status"
)
# 분할된 할일들 생성
subtasks = []
for i, (subtask_content, estimated_minutes) in enumerate(zip(split_data.subtasks, split_data.estimated_minutes_per_task)):
if estimated_minutes > 120:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Subtask {i+1} is longer than 2 hours"
)
subtask = TodoItem(
user_id=current_user.id,
content=subtask_content,
status="draft",
parent_id=original_todo.id,
split_order=i + 1
)
db.add(subtask)
subtasks.append(subtask)
# 원본 할일 상태 변경 (분할됨 표시)
original_todo.status = "split"
await db.commit()
# 응답 데이터 구성
response_data = []
for subtask in subtasks:
await db.refresh(subtask)
response_data.append(TodoItemResponse(
id=subtask.id,
user_id=subtask.user_id,
content=subtask.content,
status=subtask.status,
created_at=subtask.created_at,
start_date=subtask.start_date,
estimated_minutes=subtask.estimated_minutes,
completed_at=subtask.completed_at,
delayed_until=subtask.delayed_until,
parent_id=subtask.parent_id,
split_order=subtask.split_order,
comment_count=0
))
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in split_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to split todo item: {str(e)}"
)
@router.get("/", response_model=List[TodoItemResponse])
async def get_todo_items(
status: Optional[str] = Query(None, regex="^(draft|scheduled|active|completed|delayed)$"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 목록 조회"""
try:
query = select(TodoItem).where(TodoItem.user_id == current_user.id)
if status:
query = query.where(TodoItem.status == status)
query = query.order_by(TodoItem.created_at.desc())
result = await db.execute(query)
todo_items = result.scalars().all()
# 각 할일의 댓글 수 계산
response_data = []
for todo_item in todo_items:
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data.append(TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
))
return response_data
except Exception as e:
print(f"ERROR in get_todo_items: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo items: {str(e)}"
)
@router.get("/active", response_model=List[TodoItemResponse])
async def get_active_todos(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""오늘 활성화된 할일들 조회"""
try:
now = datetime.utcnow()
# scheduled 상태이면서 시작일이 지난 것들을 active로 변경
update_result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.user_id == current_user.id,
TodoItem.status == "scheduled",
TodoItem.start_date <= now
)
)
)
scheduled_items = update_result.scalars().all()
for item in scheduled_items:
item.status = "active"
if scheduled_items:
await db.commit()
# active 상태인 할일들 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
).order_by(TodoItem.start_date.asc())
)
active_todos = result.scalars().all()
# 응답 데이터 구성
response_data = []
for todo_item in active_todos:
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data.append(TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
))
return response_data
except Exception as e:
print(f"ERROR in get_active_todos: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get active todos: {str(e)}"
)
@router.put("/{todo_id}/complete", response_model=TodoItemResponse)
async def complete_todo_item(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 완료"""
try:
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not active"
)
todo_item.status = "completed"
todo_item.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in complete_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to complete todo item: {str(e)}"
)
@router.put("/{todo_id}/delay", response_model=TodoItemResponse)
async def delay_todo_item(
todo_id: UUID,
delay_data: TodoItemDelay,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 지연"""
try:
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not active"
)
todo_item.status = "delayed"
todo_item.delayed_until = delay_data.delayed_until
todo_item.start_date = delay_data.delayed_until # 새로운 시작일로 업데이트
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in delay_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delay todo item: {str(e)}"
)
# ============================================================================
# 댓글 관리
# ============================================================================
@router.post("/{todo_id}/comments", response_model=TodoCommentResponse)
async def create_todo_comment(
todo_id: UUID,
comment_data: TodoCommentCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일에 댓글 추가"""
try:
# 할일 존재 확인
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
new_comment = TodoComment(
todo_item_id=todo_id,
user_id=current_user.id,
content=comment_data.content
)
db.add(new_comment)
await db.commit()
await db.refresh(new_comment)
return TodoCommentResponse(
id=new_comment.id,
todo_item_id=new_comment.todo_item_id,
user_id=new_comment.user_id,
content=new_comment.content,
created_at=new_comment.created_at,
updated_at=new_comment.updated_at
)
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in create_todo_comment: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create todo comment: {str(e)}"
)
@router.get("/{todo_id}/comments", response_model=List[TodoCommentResponse])
async def get_todo_comments(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 댓글 목록 조회"""
try:
# 할일 존재 확인
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
# 댓글 조회
result = await db.execute(
select(TodoComment).where(TodoComment.todo_item_id == todo_id)
.order_by(TodoComment.created_at.asc())
)
comments = result.scalars().all()
return [
TodoCommentResponse(
id=comment.id,
todo_item_id=comment.todo_item_id,
user_id=comment.user_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at
)
for comment in comments
]
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_todo_comments: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo comments: {str(e)}"
)
@router.get("/{todo_id}", response_model=TodoItemWithComments)
async def get_todo_item_with_comments(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""댓글이 포함된 할일 상세 조회"""
try:
# 할일 조회
result = await db.execute(
select(TodoItem).options(selectinload(TodoItem.comments))
.where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
# 댓글 데이터 구성
comments = [
TodoCommentResponse(
id=comment.id,
todo_item_id=comment.todo_item_id,
user_id=comment.user_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at
)
for comment in todo_item.comments
]
return TodoItemWithComments(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=len(comments),
comments=comments
)
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_todo_item_with_comments: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo item with comments: {str(e)}"
)

View File

@@ -0,0 +1,402 @@
"""
사용자 관리 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from datetime import datetime
from ...core.database import get_db
from ...core.security import get_password_hash, verify_password
from ...models.user import User
from ..dependencies import get_current_active_user, get_current_admin_user
router = APIRouter()
class UserResponse(BaseModel):
"""사용자 응답"""
id: str
email: str
full_name: Optional[str]
is_active: bool
is_admin: bool
role: str
can_manage_books: bool
can_manage_notes: bool
can_manage_novels: bool
session_timeout_minutes: int
theme: str
language: str
timezone: str
created_at: datetime
updated_at: Optional[datetime]
last_login: Optional[datetime]
class Config:
from_attributes = True
class CreateUserRequest(BaseModel):
"""사용자 생성 요청"""
email: EmailStr
password: str
full_name: Optional[str] = None
role: str = "user"
can_manage_books: bool = True
can_manage_notes: bool = True
can_manage_novels: bool = True
session_timeout_minutes: int = 5
class UpdateUserRequest(BaseModel):
"""사용자 업데이트 요청"""
full_name: Optional[str] = None
is_active: Optional[bool] = None
role: Optional[str] = None
can_manage_books: Optional[bool] = None
can_manage_notes: Optional[bool] = None
can_manage_novels: Optional[bool] = None
session_timeout_minutes: Optional[int] = None
class UpdateProfileRequest(BaseModel):
"""프로필 업데이트 요청"""
full_name: Optional[str] = None
theme: Optional[str] = None
language: Optional[str] = None
timezone: Optional[str] = None
class ChangePasswordRequest(BaseModel):
"""비밀번호 변경 요청"""
current_password: str
new_password: str
@router.get("/me", response_model=UserResponse)
async def get_current_user_profile(
current_user: User = Depends(get_current_active_user)
):
"""현재 사용자 프로필 조회"""
return UserResponse(
id=str(current_user.id),
email=current_user.email,
full_name=current_user.full_name,
is_active=current_user.is_active,
is_admin=current_user.is_admin,
role=current_user.role,
can_manage_books=current_user.can_manage_books,
can_manage_notes=current_user.can_manage_notes,
can_manage_novels=current_user.can_manage_novels,
session_timeout_minutes=current_user.session_timeout_minutes,
theme=current_user.theme,
language=current_user.language,
timezone=current_user.timezone,
created_at=current_user.created_at,
updated_at=current_user.updated_at,
last_login=current_user.last_login
)
@router.put("/me", response_model=UserResponse)
async def update_current_user_profile(
profile_data: UpdateProfileRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""현재 사용자 프로필 업데이트"""
update_fields = profile_data.model_dump(exclude_unset=True)
for field, value in update_fields.items():
setattr(current_user, field, value)
current_user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(current_user)
return UserResponse(
id=str(current_user.id),
email=current_user.email,
full_name=current_user.full_name,
is_active=current_user.is_active,
is_admin=current_user.is_admin,
role=current_user.role,
can_manage_books=current_user.can_manage_books,
can_manage_notes=current_user.can_manage_notes,
can_manage_novels=current_user.can_manage_novels,
session_timeout_minutes=current_user.session_timeout_minutes,
theme=current_user.theme,
language=current_user.language,
timezone=current_user.timezone,
created_at=current_user.created_at,
updated_at=current_user.updated_at,
last_login=current_user.last_login
)
@router.post("/me/change-password")
async def change_current_user_password(
password_data: ChangePasswordRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""현재 사용자 비밀번호 변경"""
# 현재 비밀번호 확인
if not verify_password(password_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect"
)
# 새 비밀번호 설정
current_user.hashed_password = get_password_hash(password_data.new_password)
current_user.updated_at = datetime.utcnow()
await db.commit()
return {"message": "Password changed successfully"}
@router.get("/", response_model=List[UserResponse])
async def list_users(
skip: int = 0,
limit: int = 50,
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 목록 조회 (관리자 전용)"""
result = await db.execute(
select(User)
.order_by(User.created_at.desc())
.offset(skip)
.limit(limit)
)
users = result.scalars().all()
return [
UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
role=user.role,
can_manage_books=user.can_manage_books,
can_manage_notes=user.can_manage_notes,
can_manage_novels=user.can_manage_novels,
session_timeout_minutes=user.session_timeout_minutes,
theme=user.theme,
language=user.language,
timezone=user.timezone,
created_at=user.created_at,
updated_at=user.updated_at,
last_login=user.last_login
)
for user in users
]
@router.post("/", response_model=UserResponse)
async def create_user(
user_data: CreateUserRequest,
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 생성 (관리자 전용)"""
# 이메일 중복 확인
existing_user = await db.execute(
select(User).where(User.email == user_data.email)
)
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# 권한 확인 (root만 admin/root 계정 생성 가능)
if user_data.role in ["admin", "root"] and current_user.role != "root":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only root users can create admin accounts"
)
# 사용자 생성
hashed_password = get_password_hash(user_data.password)
new_user = User(
email=user_data.email,
hashed_password=hashed_password,
full_name=user_data.full_name,
is_active=True,
is_admin=user_data.role in ["admin", "root"],
role=user_data.role,
can_manage_books=user_data.can_manage_books,
can_manage_notes=user_data.can_manage_notes,
can_manage_novels=user_data.can_manage_novels,
session_timeout_minutes=user_data.session_timeout_minutes
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return UserResponse(
id=str(new_user.id),
email=new_user.email,
full_name=new_user.full_name,
is_active=new_user.is_active,
is_admin=new_user.is_admin,
role=new_user.role,
can_manage_books=new_user.can_manage_books,
can_manage_notes=new_user.can_manage_notes,
can_manage_novels=new_user.can_manage_novels,
session_timeout_minutes=new_user.session_timeout_minutes,
theme=new_user.theme,
language=new_user.language,
timezone=new_user.timezone,
created_at=new_user.created_at,
updated_at=new_user.updated_at,
last_login=new_user.last_login
)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 상세 조회 (관리자 전용)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
role=user.role,
can_manage_books=user.can_manage_books,
can_manage_notes=user.can_manage_notes,
can_manage_novels=user.can_manage_novels,
session_timeout_minutes=user.session_timeout_minutes,
theme=user.theme,
language=user.language,
timezone=user.timezone,
created_at=user.created_at,
updated_at=user.updated_at,
last_login=user.last_login
)
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: str,
user_data: UpdateUserRequest,
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 정보 업데이트 (관리자 전용)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# 권한 확인 (root만 admin/root 계정 수정 가능)
if user.role in ["admin", "root"] and current_user.role != "root":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only root users can modify admin accounts"
)
# 업데이트할 필드들 적용
update_fields = user_data.model_dump(exclude_unset=True)
for field, value in update_fields.items():
if field == "role":
# 역할 변경 시 is_admin도 함께 업데이트
setattr(user, field, value)
user.is_admin = value in ["admin", "root"]
else:
setattr(user, field, value)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
role=user.role,
can_manage_books=user.can_manage_books,
can_manage_notes=user.can_manage_notes,
can_manage_novels=user.can_manage_novels,
session_timeout_minutes=user.session_timeout_minutes,
theme=user.theme,
language=user.language,
timezone=user.timezone,
created_at=user.created_at,
updated_at=user.updated_at,
last_login=user.last_login
)
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 삭제 (관리자 전용)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# 자기 자신 삭제 방지
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account"
)
# root 계정 삭제 방지
if user.role == "root":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete root account"
)
# 권한 확인 (root만 admin 계정 삭제 가능)
if user.role == "admin" and current_user.role != "root":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only root users can delete admin accounts"
)
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
return {"message": "User deleted successfully"}

View File

@@ -0,0 +1,53 @@
"""
애플리케이션 설정
"""
from pydantic_settings import BaseSettings
from typing import List
import os
class Settings(BaseSettings):
"""애플리케이션 설정 클래스"""
# 기본 설정
APP_NAME: str = "Document Server"
DEBUG: bool = True
VERSION: str = "0.1.0"
# 데이터베이스 설정
DATABASE_URL: str = "postgresql+asyncpg://docuser:docpass@localhost:24101/document_db"
# Redis 설정
REDIS_URL: str = "redis://localhost:24103/0"
# JWT 설정
SECRET_KEY: str = "your-secret-key-change-this-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS 설정
ALLOWED_HOSTS: List[str] = ["http://localhost:24100", "http://127.0.0.1:24100"]
ALLOWED_ORIGINS: List[str] = ["http://localhost:24100", "http://127.0.0.1:24100"]
# 파일 업로드 설정
UPLOAD_DIR: str = "uploads"
MAX_FILE_SIZE: int = 100 * 1024 * 1024 # 100MB
ALLOWED_EXTENSIONS: List[str] = [".html", ".htm", ".pdf"]
# 관리자 계정 설정 (초기 설정용)
ADMIN_EMAIL: str = "admin@document-server.local"
ADMIN_PASSWORD: str = "admin123" # 프로덕션에서는 반드시 변경
class Config:
env_file = ".env"
case_sensitive = True
# 설정 인스턴스 생성
settings = Settings()
# 업로드 디렉토리 생성
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
os.makedirs(f"{settings.UPLOAD_DIR}/documents", exist_ok=True)
os.makedirs(f"{settings.UPLOAD_DIR}/thumbnails", exist_ok=True)

View File

@@ -0,0 +1,122 @@
"""
데이터베이스 설정 및 연결
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
from sqlalchemy import MetaData, create_engine
from typing import AsyncGenerator, Generator
from .config import settings
# SQLAlchemy 메타데이터 설정
metadata = MetaData(
naming_convention={
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
)
class Base(DeclarativeBase):
"""SQLAlchemy Base 클래스"""
metadata = metadata
# 비동기 데이터베이스 엔진 생성
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True,
pool_pre_ping=True,
pool_recycle=300,
)
# 동기 데이터베이스 엔진 생성 (노트 API용)
sync_database_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
sync_engine = create_engine(
sync_database_url,
echo=settings.DEBUG,
pool_pre_ping=True,
pool_recycle=300,
)
# 비동기 세션 팩토리
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
# 동기 세션 팩토리
SyncSessionLocal = sessionmaker(
sync_engine,
class_=Session,
expire_on_commit=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""비동기 데이터베이스 세션 의존성"""
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
def get_sync_db() -> Generator[Session, None, None]:
"""동기 데이터베이스 세션 의존성 (노트 API용)"""
session = SyncSessionLocal()
try:
yield session
except Exception:
session.rollback()
raise
finally:
session.close()
async def init_db() -> None:
"""데이터베이스 초기화"""
from ..models import user, document, highlight, note, bookmark
async with engine.begin() as conn:
# 모든 테이블 생성
await conn.run_sync(Base.metadata.create_all)
# 관리자 계정 생성
await create_admin_user()
async def create_admin_user() -> None:
"""관리자 계정 생성 (존재하지 않을 경우)"""
from ..models.user import User
from .security import get_password_hash
from sqlalchemy import select
async with AsyncSessionLocal() as session:
# 관리자 계정 존재 확인
result = await session.execute(
select(User).where(User.email == settings.ADMIN_EMAIL)
)
admin_user = result.scalar_one_or_none()
if not admin_user:
# 관리자 계정 생성
admin_user = User(
email=settings.ADMIN_EMAIL,
hashed_password=get_password_hash(settings.ADMIN_PASSWORD),
is_active=True,
is_admin=True,
full_name="Administrator"
)
session.add(admin_user)
await session.commit()
print(f"관리자 계정이 생성되었습니다: {settings.ADMIN_EMAIL}")

View File

@@ -0,0 +1,94 @@
"""
보안 관련 유틸리티
"""
from datetime import datetime, timedelta
from typing import Optional, Union
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status
from .config import settings
# 비밀번호 해싱 컨텍스트
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""비밀번호 검증"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""비밀번호 해싱"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, timeout_minutes: Optional[int] = None) -> str:
"""액세스 토큰 생성"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
elif timeout_minutes is not None:
if timeout_minutes == 0:
# 무제한 토큰 (1년으로 설정)
expire = datetime.utcnow() + timedelta(days=365)
else:
expire = datetime.utcnow() + timedelta(minutes=timeout_minutes)
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""리프레시 토큰 생성"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_token(token: str, token_type: str = "access") -> dict:
"""토큰 검증"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
# 토큰 타입 확인
if payload.get("type") != token_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
# 만료 시간 확인
exp = payload.get("exp")
if exp is None or datetime.utcnow() > datetime.fromtimestamp(exp):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired"
)
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
def get_user_id_from_token(token: str) -> str:
"""토큰에서 사용자 ID 추출"""
payload = verify_token(token)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
return user_id

86
backend/src/main.py Normal file
View File

@@ -0,0 +1,86 @@
"""
Document Server - FastAPI Main Application
"""
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
import uvicorn
from .core.config import settings
from .core.database import init_db
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup, todos
from .api.routes import note_documents, note_links
@asynccontextmanager
async def lifespan(app: FastAPI):
"""애플리케이션 시작/종료 시 실행되는 함수"""
# 시작 시 데이터베이스 초기화
await init_db()
yield
# 종료 시 정리 작업 (필요시)
# FastAPI 앱 생성
app = FastAPI(
title="Document Server",
description="HTML Document Management and Viewer System",
version="0.1.0",
lifespan=lifespan,
)
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS if hasattr(settings, 'ALLOWED_ORIGINS') else ["*"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# 정적 파일 서빙 (업로드된 파일들)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# API 라우터 등록
app.include_router(setup.router, prefix="/api/setup", tags=["시스템 설정"])
app.include_router(auth.router, prefix="/api/auth", tags=["인증"])
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/highlight-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=["검색"])
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"])
# 링크 삭제를 위한 추가 라우터 (document-links 경로 지원)
app.include_router(document_links.router, prefix="/api", tags=["문서 링크 (호환성)"])
app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서"])
app.include_router(note_links.router, prefix="/api", tags=["노트 링크"])
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
app.include_router(note_notes.router, prefix="/api", tags=["노트 메모"])
app.include_router(todos.router, prefix="/api", tags=["할일관리"])
@app.get("/")
async def root():
"""루트 엔드포인트"""
return {"message": "Document Server API", "version": "0.1.0"}
@app.get("/health")
async def health_check():
"""헬스체크 엔드포인트"""
return {"status": "healthy"}
if __name__ == "__main__":
uvicorn.run(
"src.main:app",
host="0.0.0.0",
port=8000,
reload=True if settings.DEBUG else False,
)

View File

@@ -0,0 +1,35 @@
"""
모델 패키지 초기화
"""
from .user import User
from .document import Document, Tag
from .book import Book
from .highlight import Highlight
from .note import Note
from .bookmark import Bookmark
from .document_link import DocumentLink
from .note_document import NoteDocument
from .notebook import Notebook
from .note_highlight import NoteHighlight
from .note_note import NoteNote
from .note_link import NoteLink
from .memo_tree import MemoTree, MemoNode, MemoTreeShare
__all__ = [
"User",
"Document",
"Tag",
"Book",
"Highlight",
"Note",
"Bookmark",
"DocumentLink",
"NoteDocument",
"Notebook",
"NoteHighlight",
"NoteNote",
"NoteLink",
"MemoTree",
"MemoNode",
"MemoTreeShare"
]

View File

@@ -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"<Book(title='{self.title}', author='{self.author}')>"

View File

@@ -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"<BookCategory(name='{self.name}', book='{self.book.title if self.book else None}')>"

View File

@@ -0,0 +1,42 @@
"""
책갈피 모델
"""
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 Bookmark(Base):
"""책갈피 테이블"""
__tablename__ = "bookmarks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 연결 정보
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
# 책갈피 정보
title = Column(String(200), nullable=False) # 책갈피 제목
description = Column(Text, nullable=True) # 설명
# 위치 정보
page_number = Column(Integer, nullable=True) # 페이지 번호 (추정)
scroll_position = Column(Integer, default=0) # 스크롤 위치 (픽셀)
element_id = Column(String(100), nullable=True) # 특정 요소 ID
element_selector = Column(Text, nullable=True) # CSS 선택자
# 메타데이터
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계
user = relationship("User", backref="bookmarks")
document = relationship("Document", back_populates="bookmarks")
def __repr__(self):
return f"<Bookmark(title='{self.title}', document='{self.document_id}')>"

View File

@@ -0,0 +1,87 @@
"""
문서 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Integer, Boolean, ForeignKey, Table
from sqlalchemy.dialects.postgresql import UUID, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from ..core.database import Base
# 문서-태그 다대다 관계 테이블
document_tags = Table(
'document_tags',
Base.metadata,
Column('document_id', UUID(as_uuid=True), ForeignKey('documents.id'), primary_key=True),
Column('tag_id', UUID(as_uuid=True), ForeignKey('tags.id'), primary_key=True)
)
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)
# 파일 정보
html_path = Column(String(1000), nullable=True) # HTML 파일 경로 (PDF만 업로드하는 경우 null 가능)
pdf_path = Column(String(1000), nullable=True) # PDF 원본 경로 (선택)
thumbnail_path = Column(String(1000), nullable=True) # 썸네일 경로
matched_pdf_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True) # 매칭된 PDF 문서 ID
# 메타데이터
file_size = Column(Integer, nullable=True) # 바이트 단위
page_count = Column(Integer, nullable=True) # 페이지 수 (추정)
language = Column(String(10), default="ko") # 문서 언어
# 업로드 정보
uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
original_filename = Column(String(500), nullable=True)
# 상태
is_public = Column(Boolean, default=False) # 공개 여부
is_processed = Column(Boolean, default=True) # 처리 완료 여부
# 시간 정보
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
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")
bookmarks = relationship("Bookmark", back_populates="document", cascade="all, delete-orphan")
def __repr__(self):
return f"<Document(title='{self.title}', id='{self.id}')>"
class Tag(Base):
"""태그 테이블"""
__tablename__ = "tags"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), unique=True, nullable=False, index=True)
color = Column(String(7), default="#3B82F6") # HEX 색상 코드
description = Column(Text, nullable=True)
# 메타데이터
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# 관계
creator = relationship("User", backref="created_tags")
documents = relationship("Document", secondary=document_tags, back_populates="tags")
def __repr__(self):
return f"<Tag(name='{self.name}', color='{self.color}')>"

View File

@@ -0,0 +1,53 @@
"""
문서 링크 모델
"""
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 DocumentLink(Base):
"""문서 링크 테이블"""
__tablename__ = "document_links"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 링크가 생성된 문서 (출발점)
source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, index=True)
# 링크 대상 문서 또는 노트 (도착점) - 외래키 제약 조건 제거하여 노트 ID도 허용
target_document_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# 출발점 텍스트 정보 (기존)
selected_text = Column(Text, nullable=False) # 선택된 텍스트
start_offset = Column(Integer, nullable=False) # 시작 위치
end_offset = Column(Integer, nullable=False) # 끝 위치
# 도착점 텍스트 정보 (새로 추가)
target_text = Column(Text, nullable=True) # 대상 문서에서 선택된 텍스트
target_start_offset = Column(Integer, nullable=True) # 대상 문서에서 시작 위치
target_end_offset = Column(Integer, nullable=True) # 대상 문서에서 끝 위치
# 링크 메타데이터
link_text = Column(String(500), nullable=True) # 사용자 정의 링크 텍스트 (선택사항)
description = Column(Text, nullable=True) # 링크 설명 (선택사항)
# 링크 타입 (전체 문서 vs 특정 부분)
link_type = Column(String(20), default="document", nullable=False) # "document" or "text_fragment"
# 생성자 정보
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계 - target_document는 외래키 제약 조건이 없으므로 relationship 제거
source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_links")
# target_document relationship 제거 (노트 ID도 포함할 수 있으므로)
creator = relationship("User", backref="created_links")
def __repr__(self):
return f"<DocumentLink(id='{self.id}', text='{self.selected_text[:50]}...')>"

View File

@@ -0,0 +1,47 @@
"""
하이라이트 모델
"""
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 Highlight(Base):
"""하이라이트 테이블"""
__tablename__ = "highlights"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 연결 정보
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
# 텍스트 위치 정보
start_offset = Column(Integer, nullable=False) # 시작 위치
end_offset = Column(Integer, nullable=False) # 끝 위치
selected_text = Column(Text, nullable=False) # 선택된 텍스트 (검색용)
# DOM 위치 정보 (정확한 복원을 위해)
element_selector = Column(Text, nullable=True) # CSS 선택자
start_container_xpath = Column(Text, nullable=True) # 시작 컨테이너 XPath
end_container_xpath = Column(Text, nullable=True) # 끝 컨테이너 XPath
# 스타일 정보
highlight_color = Column(String(7), default="#FFFF00") # HEX 색상 코드
highlight_type = Column(String(20), default="highlight") # highlight, underline, etc.
# 메타데이터
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계
user = relationship("User", backref="highlights")
document = relationship("Document", back_populates="highlights")
notes = relationship("Note", back_populates="highlight", cascade="all, delete-orphan")
def __repr__(self):
return f"<Highlight(id='{self.id}', text='{self.selected_text[:50]}...')>"

View File

@@ -0,0 +1,111 @@
"""
트리 구조 메모장 모델
"""
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from ..core.database import Base
class MemoTree(Base):
"""메모 트리 (프로젝트/워크스페이스)"""
__tablename__ = "memo_trees"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
title = Column(String(255), nullable=False)
description = Column(Text)
tree_type = Column(String(50), default="general") # 'novel', 'research', 'project', 'general'
template_data = Column(JSON) # 템플릿별 메타데이터
settings = Column(JSON, default={}) # 트리별 설정
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
is_public = Column(Boolean, default=False)
is_archived = Column(Boolean, default=False)
# 관계
user = relationship("User", back_populates="memo_trees")
nodes = relationship("MemoNode", back_populates="tree", cascade="all, delete-orphan")
shares = relationship("MemoTreeShare", back_populates="tree", cascade="all, delete-orphan")
class MemoNode(Base):
"""메모 노드 (트리의 각 노드)"""
__tablename__ = "memo_nodes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"))
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# 기본 정보
title = Column(String(500), nullable=False)
content = Column(Text) # Markdown 형식
node_type = Column(String(50), default="memo") # 'folder', 'memo', 'chapter', 'character', 'plot'
# 트리 구조 관리
sort_order = Column(Integer, default=0)
depth_level = Column(Integer, default=0)
path = Column(Text) # 경로 저장 (예: /1/3/7)
# 메타데이터
tags = Column(ARRAY(String)) # 태그 배열
node_metadata = Column(JSON, default={}) # 노드별 메타데이터
# 상태 관리
status = Column(String(50), default="draft") # 'draft', 'writing', 'review', 'complete'
word_count = Column(Integer, default=0)
# 정사 경로 관련 필드
is_canonical = Column(Boolean, default=False) # 정사 경로 여부
canonical_order = Column(Integer, nullable=True) # 정사 경로 순서
story_path = Column(Text, nullable=True) # 정사 경로 문자열
# 시간 정보
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# 관계
tree = relationship("MemoTree", back_populates="nodes")
user = relationship("User", back_populates="memo_nodes")
parent = relationship("MemoNode", remote_side=[id], back_populates="children")
children = relationship("MemoNode", back_populates="parent", cascade="all, delete-orphan")
versions = relationship("MemoNodeVersion", back_populates="node", cascade="all, delete-orphan")
class MemoNodeVersion(Base):
"""메모 노드 버전 관리"""
__tablename__ = "memo_node_versions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
node_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"), nullable=False)
version_number = Column(Integer, nullable=False)
title = Column(String(500), nullable=False)
content = Column(Text)
node_metadata = Column(JSON, default={})
created_at = Column(DateTime(timezone=True), server_default=func.now())
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# 관계
node = relationship("MemoNode", back_populates="versions")
creator = relationship("User")
class MemoTreeShare(Base):
"""메모 트리 공유 (협업 기능)"""
__tablename__ = "memo_tree_shares"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False)
shared_with_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
permission_level = Column(String(20), default="read") # 'read', 'write', 'admin'
created_at = Column(DateTime(timezone=True), server_default=func.now())
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# 관계
tree = relationship("MemoTree", back_populates="shares")
shared_with_user = relationship("User", foreign_keys=[shared_with_user_id])
creator = relationship("User", foreign_keys=[created_by])

View File

@@ -0,0 +1,47 @@
"""
메모 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from ..core.database import Base
class Note(Base):
"""메모 테이블 (하이라이트와 1:N 관계)"""
__tablename__ = "notes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 연결 정보
highlight_id = Column(UUID(as_uuid=True), ForeignKey("highlights.id"), nullable=False)
# 메모 내용
content = Column(Text, nullable=False)
is_private = Column(Boolean, default=True) # 개인 메모 여부
# 태그 (메모 분류용)
tags = Column(ARRAY(String), nullable=True) # ["중요", "질문", "아이디어"]
# 메타데이터
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계
highlight = relationship("Highlight", back_populates="notes")
@property
def user_id(self):
"""하이라이트를 통해 사용자 ID 가져오기"""
return self.highlight.user_id if self.highlight else None
@property
def document_id(self):
"""하이라이트를 통해 문서 ID 가져오기"""
return self.highlight.document_id if self.highlight else None
def __repr__(self):
return f"<Note(id='{self.id}', content='{self.content[:50]}...')>"

View File

@@ -0,0 +1,151 @@
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
import uuid
from ..core.database import Base
class NoteDocument(Base):
"""노트 문서 모델"""
__tablename__ = "notes_documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(500), nullable=False)
content = Column(Text) # HTML 내용 (기본)
markdown_content = Column(Text) # 마크다운 내용 (선택사항)
note_type = Column(String(50), default='note') # note, research, summary, idea 등
tags = Column(ARRAY(String), default=[])
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
created_by = Column(String(100), nullable=False)
is_published = Column(Boolean, default=False)
parent_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True)
notebook_id = Column(UUID(as_uuid=True), ForeignKey('notebooks.id'), nullable=True)
sort_order = Column(Integer, default=0)
# 관계 설정
notebook = relationship("Notebook", back_populates="notes")
highlights = relationship("NoteHighlight", back_populates="note", cascade="all, delete-orphan")
notes = relationship("NoteNote", back_populates="note", cascade="all, delete-orphan")
word_count = Column(Integer, default=0)
reading_time = Column(Integer, default=0) # 예상 읽기 시간 (분)
# 관계
parent_note = relationship("NoteDocument", remote_side=[id], back_populates="child_notes")
child_notes = relationship("NoteDocument", back_populates="parent_note")
# Pydantic 모델들
class NoteDocumentBase(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
content: Optional[str] = None
note_type: str = Field(default='note', pattern='^(note|research|summary|idea|guide|reference)$')
tags: List[str] = Field(default=[])
is_published: bool = Field(default=False)
parent_note_id: Optional[str] = None
notebook_id: Optional[str] = None
sort_order: int = Field(default=0)
class NoteDocumentCreate(NoteDocumentBase):
pass
class NoteDocumentUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=500)
content: Optional[str] = None
note_type: Optional[str] = Field(None, pattern='^(note|research|summary|idea|guide|reference)$')
tags: Optional[List[str]] = None
is_published: Optional[bool] = None
parent_note_id: Optional[str] = None
notebook_id: Optional[str] = None
sort_order: Optional[int] = None
class NoteDocumentResponse(NoteDocumentBase):
id: str
markdown_content: Optional[str] = None
created_at: datetime
updated_at: datetime
created_by: str
word_count: int
reading_time: int
# 계층 구조 정보
parent_note: Optional['NoteDocumentResponse'] = None
child_notes: List['NoteDocumentResponse'] = []
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj):
"""ORM 객체에서 Pydantic 모델로 변환"""
data = {
'id': str(obj.id), # UUID를 문자열로 변환
'title': obj.title,
'content': obj.content,
'note_type': obj.note_type,
'tags': obj.tags or [],
'is_published': obj.is_published,
'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None,
'notebook_id': str(obj.notebook_id) if obj.notebook_id else None,
'sort_order': obj.sort_order,
'markdown_content': obj.markdown_content,
'created_at': obj.created_at,
'updated_at': obj.updated_at,
'created_by': obj.created_by,
'word_count': obj.word_count or 0,
'reading_time': obj.reading_time or 0,
}
return cls(**data)
# 자기 참조 관계를 위한 모델 업데이트
NoteDocumentResponse.model_rebuild()
class NoteDocumentListItem(BaseModel):
"""노트 목록용 간소화된 모델"""
id: str
title: str
note_type: str
tags: List[str]
created_at: datetime
updated_at: datetime
created_by: str
is_published: bool
word_count: int
reading_time: int
parent_note_id: Optional[str] = None
child_count: int = 0 # 자식 노트 개수
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj, child_count=0):
"""ORM 객체에서 Pydantic 모델로 변환"""
data = {
'id': str(obj.id), # UUID를 문자열로 변환
'title': obj.title,
'note_type': obj.note_type,
'tags': obj.tags or [],
'created_at': obj.created_at,
'updated_at': obj.updated_at,
'created_by': obj.created_by,
'is_published': obj.is_published,
'word_count': obj.word_count or 0,
'reading_time': obj.reading_time or 0,
'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None,
'child_count': child_count,
}
return cls(**data)
class NoteStats(BaseModel):
"""노트 통계 정보"""
total_notes: int
published_notes: int
draft_notes: int
note_types: dict # {type: count}
total_words: int
total_reading_time: int
recent_notes: List[NoteDocumentListItem]

View File

@@ -0,0 +1,69 @@
from sqlalchemy import Column, String, Integer, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
import uuid
from ..core.database import Base
class NoteHighlight(Base):
"""노트 하이라이트 모델"""
__tablename__ = "note_highlights"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
note_id = Column(UUID(as_uuid=True), ForeignKey("notes_documents.id", ondelete="CASCADE"), nullable=False)
start_offset = Column(Integer, nullable=False)
end_offset = Column(Integer, nullable=False)
selected_text = Column(Text, nullable=False)
highlight_color = Column(String(50), nullable=False, default='#FFFF00')
highlight_type = Column(String(50), nullable=False, default='highlight')
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
created_by = Column(String(100), nullable=False)
# 관계
note = relationship("NoteDocument", back_populates="highlights")
notes = relationship("NoteNote", back_populates="highlight", cascade="all, delete-orphan")
# Pydantic 모델들
class NoteHighlightBase(BaseModel):
note_id: str
start_offset: int
end_offset: int
selected_text: str
highlight_color: str = '#FFFF00'
highlight_type: str = 'highlight'
class NoteHighlightCreate(NoteHighlightBase):
pass
class NoteHighlightUpdate(BaseModel):
highlight_color: Optional[str] = None
highlight_type: Optional[str] = None
class NoteHighlightResponse(NoteHighlightBase):
id: str
created_at: datetime
updated_at: datetime
created_by: str
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj):
return cls(
id=str(obj.id),
note_id=str(obj.note_id),
start_offset=obj.start_offset,
end_offset=obj.end_offset,
selected_text=obj.selected_text,
highlight_color=obj.highlight_color,
highlight_type=obj.highlight_type,
created_at=obj.created_at,
updated_at=obj.updated_at,
created_by=obj.created_by
)

View File

@@ -0,0 +1,58 @@
"""
노트 문서 링크 모델
"""
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 NoteLink(Base):
"""노트 문서 링크 테이블"""
__tablename__ = "note_links"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 링크가 생성된 노트 (출발점) - 노트 문서 또는 일반 문서 가능
source_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True, index=True)
source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True, index=True)
# 링크 대상 노트 (도착점) - 노트 문서 또는 일반 문서 가능
target_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True, index=True)
target_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True, index=True)
# 출발점 텍스트 정보
selected_text = Column(Text, nullable=False) # 선택된 텍스트
start_offset = Column(Integer, nullable=False) # 시작 위치
end_offset = Column(Integer, nullable=False) # 끝 위치
# 도착점 텍스트 정보
target_text = Column(Text, nullable=True) # 대상에서 선택된 텍스트
target_start_offset = Column(Integer, nullable=True) # 대상에서 시작 위치
target_end_offset = Column(Integer, nullable=True) # 대상에서 끝 위치
# 링크 메타데이터
link_text = Column(String(500), nullable=True) # 사용자 정의 링크 텍스트
description = Column(Text, nullable=True) # 링크 설명
# 링크 타입
link_type = Column(String(20), default="note", nullable=False) # "note", "document", "text_fragment"
# 생성자 정보
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계 설정
source_note = relationship("NoteDocument", foreign_keys=[source_note_id], backref="outgoing_note_links")
source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_note_links")
target_note = relationship("NoteDocument", foreign_keys=[target_note_id], backref="incoming_note_links")
target_document = relationship("Document", foreign_keys=[target_document_id], backref="incoming_note_links")
creator = relationship("User", backref="created_note_links")
def __repr__(self):
return f"<NoteLink(id={self.id}, source_note={self.source_note_id}, target_note={self.target_note_id})>"

View File

@@ -0,0 +1,59 @@
from sqlalchemy import Column, String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
import uuid
from ..core.database import Base
class NoteNote(Base):
"""노트의 메모 모델 (노트 안의 하이라이트에 대한 메모)"""
__tablename__ = "note_notes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
note_id = Column(UUID(as_uuid=True), ForeignKey("notes_documents.id", ondelete="CASCADE"), nullable=False)
highlight_id = Column(UUID(as_uuid=True), ForeignKey("note_highlights.id", ondelete="CASCADE"), nullable=True)
content = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
created_by = Column(String(100), nullable=False)
# 관계
note = relationship("NoteDocument", back_populates="notes")
highlight = relationship("NoteHighlight", back_populates="notes")
# Pydantic 모델들
class NoteNoteBase(BaseModel):
note_id: str
highlight_id: Optional[str] = None
content: str
class NoteNoteCreate(NoteNoteBase):
pass
class NoteNoteUpdate(BaseModel):
content: Optional[str] = None
class NoteNoteResponse(NoteNoteBase):
id: str
created_at: datetime
updated_at: datetime
created_by: str
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj):
return cls(
id=str(obj.id),
note_id=str(obj.note_id),
highlight_id=str(obj.highlight_id) if obj.highlight_id else None,
content=obj.content,
created_at=obj.created_at,
updated_at=obj.updated_at,
created_by=obj.created_by
)

View File

@@ -0,0 +1,126 @@
"""
노트북 모델
"""
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
import uuid
from ..core.database import Base
class Notebook(Base):
"""노트북 테이블"""
__tablename__ = "notebooks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
color = Column(String(7), default='#3B82F6') # 헥스 컬러 코드
icon = Column(String(50), default='book') # FontAwesome 아이콘
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
created_by = Column(String(100), nullable=False)
is_active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
# 관계 설정 (노트들)
notes = relationship("NoteDocument", back_populates="notebook")
# Pydantic 모델들
class NotebookBase(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
description: Optional[str] = None
color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$')
icon: str = Field(default='book', min_length=1, max_length=50)
is_active: bool = True
sort_order: int = 0
class NotebookCreate(NotebookBase):
pass
class NotebookUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=500)
description: Optional[str] = None
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
icon: Optional[str] = Field(None, min_length=1, max_length=50)
is_active: Optional[bool] = None
sort_order: Optional[int] = None
class NotebookResponse(NotebookBase):
id: str
created_at: datetime
updated_at: datetime
created_by: str
note_count: int = 0 # 포함된 노트 개수
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj, note_count=0):
"""ORM 객체에서 Pydantic 모델로 변환"""
data = {
'id': str(obj.id),
'title': obj.title,
'description': obj.description,
'color': obj.color,
'icon': obj.icon,
'created_at': obj.created_at,
'updated_at': obj.updated_at,
'created_by': obj.created_by,
'is_active': obj.is_active,
'sort_order': obj.sort_order,
'note_count': note_count,
}
return cls(**data)
class NotebookListItem(BaseModel):
"""노트북 목록용 간소화된 모델"""
id: str
title: str
description: Optional[str]
color: str
icon: str
created_at: datetime
updated_at: datetime
created_by: str
is_active: bool
note_count: int = 0
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj, note_count=0):
"""ORM 객체에서 Pydantic 모델로 변환"""
data = {
'id': str(obj.id),
'title': obj.title,
'description': obj.description,
'color': obj.color,
'icon': obj.icon,
'created_at': obj.created_at,
'updated_at': obj.updated_at,
'created_by': obj.created_by,
'is_active': obj.is_active,
'note_count': note_count,
}
return cls(**data)
class NotebookStats(BaseModel):
"""노트북 통계 정보"""
total_notebooks: int
active_notebooks: int
total_notes: int
notes_without_notebook: int

View File

@@ -0,0 +1,63 @@
"""
할일관리 시스템 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
from ..core.database import Base
class TodoItem(Base):
"""할일 아이템"""
__tablename__ = "todo_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
# 기본 정보
content = Column(Text, nullable=False) # 할일 내용
status = Column(String(20), nullable=False, default="draft") # draft, scheduled, active, completed, delayed
# 시간 관리
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
start_date = Column(DateTime(timezone=True), nullable=True) # 시작 예정일
estimated_minutes = Column(Integer, nullable=True) # 예상 소요시간 (분)
completed_at = Column(DateTime(timezone=True), nullable=True)
delayed_until = Column(DateTime(timezone=True), nullable=True) # 지연된 경우 새로운 시작일
# 분할 관리
parent_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=True) # 분할된 할일의 부모
split_order = Column(Integer, nullable=True) # 분할 순서
# 관계
user = relationship("User", back_populates="todo_items")
comments = relationship("TodoComment", back_populates="todo_item", cascade="all, delete-orphan")
# 자기 참조 관계 (분할된 할일들)
subtasks = relationship("TodoItem", backref="parent_task", remote_side=[id])
def __repr__(self):
return f"<TodoItem(id={self.id}, content='{self.content[:50]}...', status='{self.status}')>"
class TodoComment(Base):
"""할일 댓글/메모"""
__tablename__ = "todo_comments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
todo_item_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
# 관계
todo_item = relationship("TodoItem", back_populates="comments")
user = relationship("User")
def __repr__(self):
return f"<TodoComment(id={self.id}, content='{self.content[:30]}...')>"

View File

@@ -0,0 +1,51 @@
"""
사용자 모델
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from ..core.database import Base
class User(Base):
"""사용자 테이블"""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
# 권한 시스템 (서적관리, 노트관리, 소설관리)
can_manage_books = Column(Boolean, default=True) # 서적 관리 권한
can_manage_notes = Column(Boolean, default=True) # 노트 관리 권한
can_manage_novels = Column(Boolean, default=True) # 소설 관리 권한
# 사용자 역할 (root, admin, user)
role = Column(String(20), default="user") # root, admin, user
# 세션 타임아웃 설정 (분 단위, 0 = 무제한)
session_timeout_minutes = Column(Integer, default=5) # 기본 5분
# 메타데이터
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
last_login = Column(DateTime(timezone=True), nullable=True)
# 사용자 설정
theme = Column(String(50), default="light") # light, dark
language = Column(String(10), default="ko") # ko, en
timezone = Column(String(50), default="Asia/Seoul")
# 관계 (lazy loading을 위해 문자열로 참조)
memo_trees = relationship("MemoTree", back_populates="user", lazy="dynamic")
memo_nodes = relationship("MemoNode", back_populates="user", lazy="dynamic")
todo_items = relationship("TodoItem", back_populates="user", lazy="dynamic")
def __repr__(self):
return f"<User(email='{self.email}', full_name='{self.full_name}')>"

View File

@@ -0,0 +1,63 @@
"""
인증 관련 스키마
"""
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
from uuid import UUID
class LoginRequest(BaseModel):
"""로그인 요청"""
email: EmailStr
password: str
class TokenResponse(BaseModel):
"""토큰 응답"""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int # 초 단위
class RefreshTokenRequest(BaseModel):
"""토큰 갱신 요청"""
refresh_token: str
class UserInfo(BaseModel):
"""사용자 정보"""
id: UUID
email: str
full_name: Optional[str] = None
is_active: bool
is_admin: bool
role: str
can_manage_books: bool
can_manage_notes: bool
can_manage_novels: bool
session_timeout_minutes: int
theme: str
language: str
timezone: str
created_at: datetime
updated_at: Optional[datetime] = None
last_login: Optional[datetime] = None
class Config:
from_attributes = True
class ChangePasswordRequest(BaseModel):
"""비밀번호 변경 요청"""
current_password: str
new_password: str
class CreateUserRequest(BaseModel):
"""사용자 생성 요청 (관리자용)"""
email: EmailStr
password: str
full_name: Optional[str] = None
is_admin: bool = False

View File

@@ -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)

View File

@@ -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}, ...]

View File

@@ -0,0 +1,205 @@
"""
트리 구조 메모장 Pydantic 스키마
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime
from uuid import UUID
# 기본 스키마들
class MemoTreeBase(BaseModel):
"""메모 트리 기본 스키마"""
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
tree_type: str = Field(default="general", pattern="^(general|novel|research|project)$")
template_data: Optional[Dict[str, Any]] = None
settings: Optional[Dict[str, Any]] = None
is_public: bool = False
class MemoTreeCreate(MemoTreeBase):
"""메모 트리 생성 요청"""
pass
class MemoTreeUpdate(BaseModel):
"""메모 트리 업데이트 요청"""
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
tree_type: Optional[str] = Field(None, pattern="^(general|novel|research|project)$")
template_data: Optional[Dict[str, Any]] = None
settings: Optional[Dict[str, Any]] = None
is_public: Optional[bool] = None
is_archived: Optional[bool] = None
class MemoTreeResponse(MemoTreeBase):
"""메모 트리 응답"""
id: str
user_id: str
created_at: datetime
updated_at: Optional[datetime]
is_archived: bool
node_count: Optional[int] = 0 # 노드 개수
class Config:
from_attributes = True
# 메모 노드 스키마들
class MemoNodeBase(BaseModel):
"""메모 노드 기본 스키마"""
title: str = Field(..., min_length=1, max_length=500)
content: Optional[str] = None
node_type: str = Field(default="memo", pattern="^(folder|memo|chapter|character|plot)$")
tags: Optional[List[str]] = None
node_metadata: Optional[Dict[str, Any]] = None
status: str = Field(default="draft", pattern="^(draft|writing|review|complete)$")
# 정사 경로 관련 필드
is_canonical: Optional[bool] = False
canonical_order: Optional[int] = None
class MemoNodeCreate(MemoNodeBase):
"""메모 노드 생성 요청"""
tree_id: str
parent_id: Optional[str] = None
sort_order: Optional[int] = 0
class MemoNodeUpdate(BaseModel):
"""메모 노드 업데이트 요청"""
title: Optional[str] = Field(None, min_length=1, max_length=500)
content: Optional[str] = None
node_type: Optional[str] = Field(None, pattern="^(folder|memo|chapter|character|plot)$")
parent_id: Optional[str] = None
sort_order: Optional[int] = None
tags: Optional[List[str]] = None
node_metadata: Optional[Dict[str, Any]] = None
status: Optional[str] = Field(None, pattern="^(draft|writing|review|complete)$")
# 정사 경로 관련 필드
is_canonical: Optional[bool] = None
canonical_order: Optional[int] = None
class MemoNodeMove(BaseModel):
"""메모 노드 이동 요청"""
parent_id: Optional[str] = None
sort_order: int = 0
class MemoNodeResponse(MemoNodeBase):
"""메모 노드 응답"""
id: str
tree_id: str
parent_id: Optional[str]
user_id: str
sort_order: int
depth_level: int
path: Optional[str]
word_count: int
created_at: datetime
updated_at: Optional[datetime]
# 정사 경로 관련 필드
is_canonical: bool
canonical_order: Optional[int]
story_path: Optional[str]
# 관계 데이터
children_count: Optional[int] = 0
class Config:
from_attributes = True
# 트리 구조 응답
class MemoTreeWithNodes(MemoTreeResponse):
"""노드가 포함된 메모 트리 응답"""
nodes: List[MemoNodeResponse] = []
# 노드 버전 스키마들
class MemoNodeVersionResponse(BaseModel):
"""메모 노드 버전 응답"""
id: str
node_id: str
version_number: int
title: str
content: Optional[str]
node_metadata: Optional[Dict[str, Any]]
created_at: datetime
created_by: str
class Config:
from_attributes = True
# 공유 스키마들
class MemoTreeShareCreate(BaseModel):
"""메모 트리 공유 생성 요청"""
shared_with_user_email: str
permission_level: str = Field(default="read", pattern="^(read|write|admin)$")
class MemoTreeShareResponse(BaseModel):
"""메모 트리 공유 응답"""
id: str
tree_id: str
shared_with_user_id: str
shared_with_user_email: str
shared_with_user_name: str
permission_level: str
created_at: datetime
created_by: str
class Config:
from_attributes = True
# 검색 및 필터링
class MemoSearchRequest(BaseModel):
"""메모 검색 요청"""
query: str = Field(..., min_length=1)
tree_id: Optional[str] = None
node_types: Optional[List[str]] = None
tags: Optional[List[str]] = None
status: Optional[List[str]] = None
class MemoSearchResult(BaseModel):
"""메모 검색 결과"""
node: MemoNodeResponse
tree: MemoTreeResponse
matches: List[Dict[str, Any]] # 매치된 부분들
relevance_score: float
# 통계 스키마
class MemoTreeStats(BaseModel):
"""메모 트리 통계"""
total_nodes: int
nodes_by_type: Dict[str, int]
nodes_by_status: Dict[str, int]
total_words: int
last_updated: Optional[datetime]
# 내보내기 스키마
class ExportRequest(BaseModel):
"""내보내기 요청"""
tree_id: str
format: str = Field(..., pattern="^(markdown|html|pdf|docx)$")
include_metadata: bool = True
node_types: Optional[List[str]] = None
class ExportResponse(BaseModel):
"""내보내기 응답"""
file_url: str
file_name: str
file_size: int
created_at: datetime

108
backend/src/schemas/todo.py Normal file
View File

@@ -0,0 +1,108 @@
"""
할일관리 시스템 스키마
"""
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from uuid import UUID
class TodoCommentBase(BaseModel):
content: str = Field(..., min_length=1, max_length=1000)
class TodoCommentCreate(TodoCommentBase):
pass
class TodoCommentUpdate(BaseModel):
content: Optional[str] = Field(None, min_length=1, max_length=1000)
class TodoCommentResponse(TodoCommentBase):
id: UUID
todo_item_id: UUID
user_id: UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TodoItemBase(BaseModel):
content: str = Field(..., min_length=1, max_length=2000)
class TodoItemCreate(TodoItemBase):
"""초기 할일 생성 (draft 상태)"""
pass
class TodoItemSchedule(BaseModel):
"""할일 일정 설정"""
start_date: datetime
estimated_minutes: int = Field(..., ge=1, le=120) # 1분~2시간
class TodoItemUpdate(BaseModel):
"""할일 수정"""
content: Optional[str] = Field(None, min_length=1, max_length=2000)
status: Optional[str] = Field(None, pattern="^(draft|scheduled|active|completed|delayed)$")
start_date: Optional[datetime] = None
estimated_minutes: Optional[int] = Field(None, ge=1, le=120)
delayed_until: Optional[datetime] = None
class TodoItemDelay(BaseModel):
"""할일 지연"""
delayed_until: datetime
class TodoItemSplit(BaseModel):
"""할일 분할"""
subtasks: List[str] = Field(..., min_items=2, max_items=10)
estimated_minutes_per_task: List[int] = Field(..., min_items=2, max_items=10)
class TodoItemResponse(TodoItemBase):
id: UUID
user_id: UUID
status: str
created_at: datetime
start_date: Optional[datetime]
estimated_minutes: Optional[int]
completed_at: Optional[datetime]
delayed_until: Optional[datetime]
parent_id: Optional[UUID]
split_order: Optional[int]
# 댓글 수
comment_count: int = 0
class Config:
from_attributes = True
class TodoItemWithComments(TodoItemResponse):
"""댓글이 포함된 할일 응답"""
comments: List[TodoCommentResponse] = []
class TodoStats(BaseModel):
"""할일 통계"""
total_count: int
draft_count: int
scheduled_count: int
active_count: int
completed_count: int
delayed_count: int
completion_rate: float # 완료율 (%)
class TodoDashboard(BaseModel):
"""할일 대시보드"""
stats: TodoStats
today_todos: List[TodoItemResponse]
overdue_todos: List[TodoItemResponse]
upcoming_todos: List[TodoItemResponse]