🚀 배포용: 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:
3
backend/src/__init__.py
Normal file
3
backend/src/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Document Server Backend Package
|
||||
"""
|
||||
3
backend/src/api/__init__.py
Normal file
3
backend/src/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API 패키지 초기화
|
||||
"""
|
||||
149
backend/src/api/dependencies.py
Normal file
149
backend/src/api/dependencies.py
Normal 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"
|
||||
)
|
||||
3
backend/src/api/routes/__init__.py
Normal file
3
backend/src/api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API 라우터 패키지 초기화
|
||||
"""
|
||||
193
backend/src/api/routes/auth.py
Normal file
193
backend/src/api/routes/auth.py
Normal 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"}
|
||||
155
backend/src/api/routes/book_categories.py
Normal file
155
backend/src/api/routes/book_categories.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from ...core.database import get_db
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...models.user import User
|
||||
from ...models.book import Book
|
||||
from ...models.book_category import BookCategory
|
||||
from ...models.document import Document
|
||||
from ...schemas.book_category import (
|
||||
CreateBookCategoryRequest,
|
||||
UpdateBookCategoryRequest,
|
||||
BookCategoryResponse,
|
||||
UpdateDocumentOrderRequest
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=BookCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_book_category(
|
||||
category_data: CreateBookCategoryRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새로운 서적 소분류 생성"""
|
||||
# 서적 존재 확인
|
||||
book_result = await db.execute(select(Book).where(Book.id == category_data.book_id))
|
||||
book = book_result.scalar_one_or_none()
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# 권한 확인 (관리자만)
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can create categories")
|
||||
|
||||
new_category = BookCategory(**category_data.model_dump())
|
||||
db.add(new_category)
|
||||
await db.commit()
|
||||
await db.refresh(new_category)
|
||||
|
||||
return await _get_category_response(db, new_category)
|
||||
|
||||
@router.get("/book/{book_id}", response_model=List[BookCategoryResponse])
|
||||
async def get_book_categories(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 서적의 소분류 목록 조회"""
|
||||
result = await db.execute(
|
||||
select(BookCategory)
|
||||
.where(BookCategory.book_id == book_id)
|
||||
.order_by(BookCategory.sort_order, BookCategory.name)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
|
||||
response_categories = []
|
||||
for category in categories:
|
||||
response_categories.append(await _get_category_response(db, category))
|
||||
return response_categories
|
||||
|
||||
@router.put("/{category_id}", response_model=BookCategoryResponse)
|
||||
async def update_book_category(
|
||||
category_id: UUID,
|
||||
category_data: UpdateBookCategoryRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 소분류 수정"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update categories")
|
||||
|
||||
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
|
||||
for field, value in category_data.model_dump(exclude_unset=True).items():
|
||||
setattr(category, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(category)
|
||||
return await _get_category_response(db, category)
|
||||
|
||||
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_book_category(
|
||||
category_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 소분류 삭제 (포함된 문서들은 미분류로 이동)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete categories")
|
||||
|
||||
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
|
||||
# 포함된 문서들을 미분류로 이동 (category_id를 NULL로 설정)
|
||||
await db.execute(
|
||||
update(Document)
|
||||
.where(Document.category_id == category_id)
|
||||
.values(category_id=None)
|
||||
)
|
||||
|
||||
await db.delete(category)
|
||||
await db.commit()
|
||||
return {"message": "Category deleted successfully"}
|
||||
|
||||
@router.put("/documents/reorder", status_code=status.HTTP_200_OK)
|
||||
async def update_document_order(
|
||||
order_data: UpdateDocumentOrderRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 순서 변경"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can reorder documents")
|
||||
|
||||
# 문서 순서 업데이트
|
||||
for item in order_data.document_orders:
|
||||
document_id = item.get("document_id")
|
||||
sort_order = item.get("sort_order", 0)
|
||||
|
||||
await db.execute(
|
||||
update(Document)
|
||||
.where(Document.id == document_id)
|
||||
.values(sort_order=sort_order)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Document order updated successfully"}
|
||||
|
||||
# Helper function
|
||||
async def _get_category_response(db: AsyncSession, category: BookCategory) -> BookCategoryResponse:
|
||||
"""BookCategory를 BookCategoryResponse로 변환"""
|
||||
document_count_result = await db.execute(
|
||||
select(func.count(Document.id)).where(Document.category_id == category.id)
|
||||
)
|
||||
document_count = document_count_result.scalar_one()
|
||||
|
||||
return BookCategoryResponse(
|
||||
id=category.id,
|
||||
book_id=category.book_id,
|
||||
name=category.name,
|
||||
description=category.description,
|
||||
sort_order=category.sort_order,
|
||||
created_at=category.created_at,
|
||||
updated_at=category.updated_at,
|
||||
document_count=document_count
|
||||
)
|
||||
300
backend/src/api/routes/bookmarks.py
Normal file
300
backend/src/api/routes/bookmarks.py
Normal 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"}
|
||||
230
backend/src/api/routes/books.py
Normal file
230
backend/src/api/routes/books.py
Normal file
@@ -0,0 +1,230 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import difflib # For similarity suggestions
|
||||
|
||||
from ...core.database import get_db
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...models.user import User
|
||||
from ...models.book import Book
|
||||
from ...models.document import Document
|
||||
from ...schemas.book import CreateBookRequest, UpdateBookRequest, BookResponse, BookSearchResponse, BookSuggestionResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Helper to convert Book ORM object to BookResponse
|
||||
async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse:
|
||||
document_count_result = await db.execute(
|
||||
select(func.count(Document.id)).where(Document.book_id == book.id)
|
||||
)
|
||||
document_count = document_count_result.scalar_one()
|
||||
return BookResponse(
|
||||
id=book.id,
|
||||
title=book.title,
|
||||
author=book.author,
|
||||
description=book.description,
|
||||
language=book.language,
|
||||
is_public=book.is_public,
|
||||
created_at=book.created_at,
|
||||
updated_at=book.updated_at,
|
||||
document_count=document_count
|
||||
)
|
||||
|
||||
@router.post("", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_book(
|
||||
book_data: CreateBookRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새로운 서적 생성"""
|
||||
# Check if a book with the same title and author already exists for the user
|
||||
existing_book_query = select(Book).where(Book.title == book_data.title)
|
||||
if book_data.author:
|
||||
existing_book_query = existing_book_query.where(Book.author == book_data.author)
|
||||
|
||||
existing_book = await db.execute(existing_book_query)
|
||||
if existing_book.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A book with this title and author already exists."
|
||||
)
|
||||
|
||||
new_book = Book(**book_data.model_dump())
|
||||
db.add(new_book)
|
||||
await db.commit()
|
||||
await db.refresh(new_book)
|
||||
return await _get_book_response(db, new_book)
|
||||
|
||||
@router.get("", response_model=List[BookResponse])
|
||||
async def get_books(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = Query(None, description="Search by book title or author"),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""모든 서적 목록 조회"""
|
||||
query = select(Book)
|
||||
if search:
|
||||
query = query.where(
|
||||
or_(
|
||||
Book.title.ilike(f"%{search}%"),
|
||||
Book.author.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
# Only show public books or books owned by the current user/admin
|
||||
if not current_user.is_admin:
|
||||
query = query.where(Book.is_public == True) # For simplicity, assuming all books are public for now or user can only see public ones.
|
||||
# In a real app, you'd link books to users.
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Book.title)
|
||||
result = await db.execute(query)
|
||||
books = result.scalars().all()
|
||||
|
||||
response_books = []
|
||||
for book in books:
|
||||
response_books.append(await _get_book_response(db, book))
|
||||
return response_books
|
||||
|
||||
@router.get("/{book_id}", response_model=BookResponse)
|
||||
async def get_book(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 서적 상세 정보 조회"""
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# Access control (simplified)
|
||||
if not book.is_public and not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions to access this book")
|
||||
|
||||
return await _get_book_response(db, book)
|
||||
|
||||
@router.put("/{book_id}", response_model=BookResponse)
|
||||
async def update_book(
|
||||
book_id: UUID,
|
||||
book_data: UpdateBookRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 정보 업데이트"""
|
||||
if not current_user.is_admin: # Only admin can update books for now
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update books")
|
||||
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
for field, value in book_data.model_dump(exclude_unset=True).items():
|
||||
setattr(book, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(book)
|
||||
return await _get_book_response(db, book)
|
||||
|
||||
@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_book(
|
||||
book_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 삭제"""
|
||||
if not current_user.is_admin: # Only admin can delete books for now
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete books")
|
||||
|
||||
result = await db.execute(
|
||||
select(Book).where(Book.id == book_id)
|
||||
)
|
||||
book = result.scalar_one_or_none()
|
||||
|
||||
if not book:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
|
||||
|
||||
# Disassociate documents from this book before deleting
|
||||
await db.execute(
|
||||
select(Document).where(Document.book_id == book_id)
|
||||
)
|
||||
documents_to_update = (await db.execute(select(Document).where(Document.book_id == book_id))).scalars().all()
|
||||
for doc in documents_to_update:
|
||||
doc.book_id = None
|
||||
|
||||
await db.delete(book)
|
||||
await db.commit()
|
||||
return {"message": "Book deleted successfully"}
|
||||
|
||||
@router.get("/search/", response_model=List[BookSearchResponse])
|
||||
async def search_books(
|
||||
q: str = Query(..., min_length=1, description="Search query for book title or author"),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""서적 검색 (제목 또는 저자)"""
|
||||
query = select(Book).where(
|
||||
or_(
|
||||
Book.title.ilike(f"%{q}%"),
|
||||
Book.author.ilike(f"%{q}%")
|
||||
)
|
||||
)
|
||||
if not current_user.is_admin:
|
||||
query = query.where(Book.is_public == True)
|
||||
|
||||
result = await db.execute(query.limit(limit))
|
||||
books = result.scalars().all()
|
||||
|
||||
response_books = []
|
||||
for book in books:
|
||||
response_books.append(await _get_book_response(db, book))
|
||||
return response_books
|
||||
|
||||
@router.get("/suggestions/", response_model=List[BookSuggestionResponse])
|
||||
async def get_book_suggestions(
|
||||
title: str = Query(..., min_length=1, description="Book title for suggestions"),
|
||||
limit: int = Query(5, ge=1, le=10),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""제목 유사도 기반 서적 추천"""
|
||||
all_books_query = select(Book)
|
||||
if not current_user.is_admin:
|
||||
all_books_query = all_books_query.where(Book.is_public == True)
|
||||
|
||||
all_books_result = await db.execute(all_books_query)
|
||||
all_books = all_books_result.scalars().all()
|
||||
|
||||
suggestions = []
|
||||
for book in all_books:
|
||||
# Calculate similarity score using difflib
|
||||
score = difflib.SequenceMatcher(None, title.lower(), book.title.lower()).ratio()
|
||||
if score > 0.1: # Only consider if there's some similarity
|
||||
suggestions.append({
|
||||
"book": book,
|
||||
"similarity_score": score
|
||||
})
|
||||
|
||||
# Sort by similarity score in descending order
|
||||
suggestions.sort(key=lambda x: x["similarity_score"], reverse=True)
|
||||
|
||||
response_suggestions = []
|
||||
for s in suggestions[:limit]:
|
||||
book_response = await _get_book_response(db, s["book"])
|
||||
response_suggestions.append(BookSuggestionResponse(
|
||||
**book_response.model_dump(),
|
||||
similarity_score=s["similarity_score"]
|
||||
))
|
||||
return response_suggestions
|
||||
690
backend/src/api/routes/document_links.py
Normal file
690
backend/src/api/routes/document_links.py
Normal 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
|
||||
1140
backend/src/api/routes/documents.py
Normal file
1140
backend/src/api/routes/documents.py
Normal file
File diff suppressed because it is too large
Load Diff
471
backend/src/api/routes/highlights.py
Normal file
471
backend/src/api/routes/highlights.py
Normal 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
|
||||
700
backend/src/api/routes/memo_trees.py
Normal file
700
backend/src/api/routes/memo_trees.py
Normal 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)}"
|
||||
)
|
||||
271
backend/src/api/routes/note_documents.py
Normal file
271
backend/src/api/routes/note_documents.py
Normal 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 ""
|
||||
103
backend/src/api/routes/note_highlights.py
Normal file
103
backend/src/api/routes/note_highlights.py
Normal 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"}
|
||||
291
backend/src/api/routes/note_links.py
Normal file
291
backend/src/api/routes/note_links.py
Normal 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"}
|
||||
128
backend/src/api/routes/note_notes.py
Normal file
128
backend/src/api/routes/note_notes.py
Normal 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"}
|
||||
270
backend/src/api/routes/notebooks.py
Normal file
270
backend/src/api/routes/notebooks.py
Normal 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"}
|
||||
532
backend/src/api/routes/notes.py
Normal file
532
backend/src/api/routes/notes.py
Normal 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}"}
|
||||
)
|
||||
671
backend/src/api/routes/search.py
Normal file
671
backend/src/api/routes/search.py
Normal 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
|
||||
104
backend/src/api/routes/setup.py
Normal file
104
backend/src/api/routes/setup.py
Normal 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
|
||||
}
|
||||
}
|
||||
663
backend/src/api/routes/todos.py
Normal file
663
backend/src/api/routes/todos.py
Normal 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)}"
|
||||
)
|
||||
402
backend/src/api/routes/users.py
Normal file
402
backend/src/api/routes/users.py
Normal 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"}
|
||||
53
backend/src/core/config.py
Normal file
53
backend/src/core/config.py
Normal 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)
|
||||
122
backend/src/core/database.py
Normal file
122
backend/src/core/database.py
Normal 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}")
|
||||
94
backend/src/core/security.py
Normal file
94
backend/src/core/security.py
Normal 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
86
backend/src/main.py
Normal 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,
|
||||
)
|
||||
35
backend/src/models/__init__.py
Normal file
35
backend/src/models/__init__.py
Normal 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"
|
||||
]
|
||||
27
backend/src/models/book.py
Normal file
27
backend/src/models/book.py
Normal 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}')>"
|
||||
26
backend/src/models/book_category.py
Normal file
26
backend/src/models/book_category.py
Normal 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}')>"
|
||||
42
backend/src/models/bookmark.py
Normal file
42
backend/src/models/bookmark.py
Normal 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}')>"
|
||||
87
backend/src/models/document.py
Normal file
87
backend/src/models/document.py
Normal 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}')>"
|
||||
53
backend/src/models/document_link.py
Normal file
53
backend/src/models/document_link.py
Normal 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]}...')>"
|
||||
47
backend/src/models/highlight.py
Normal file
47
backend/src/models/highlight.py
Normal 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]}...')>"
|
||||
111
backend/src/models/memo_tree.py
Normal file
111
backend/src/models/memo_tree.py
Normal 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])
|
||||
47
backend/src/models/note.py
Normal file
47
backend/src/models/note.py
Normal 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]}...')>"
|
||||
151
backend/src/models/note_document.py
Normal file
151
backend/src/models/note_document.py
Normal 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]
|
||||
69
backend/src/models/note_highlight.py
Normal file
69
backend/src/models/note_highlight.py
Normal 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
|
||||
)
|
||||
58
backend/src/models/note_link.py
Normal file
58
backend/src/models/note_link.py
Normal 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})>"
|
||||
|
||||
59
backend/src/models/note_note.py
Normal file
59
backend/src/models/note_note.py
Normal 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
|
||||
)
|
||||
126
backend/src/models/notebook.py
Normal file
126
backend/src/models/notebook.py
Normal 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
|
||||
63
backend/src/models/todo.py
Normal file
63
backend/src/models/todo.py
Normal 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]}...')>"
|
||||
51
backend/src/models/user.py
Normal file
51
backend/src/models/user.py
Normal 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}')>"
|
||||
63
backend/src/schemas/auth.py
Normal file
63
backend/src/schemas/auth.py
Normal 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
|
||||
32
backend/src/schemas/book.py
Normal file
32
backend/src/schemas/book.py
Normal 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)
|
||||
31
backend/src/schemas/book_category.py
Normal file
31
backend/src/schemas/book_category.py
Normal 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}, ...]
|
||||
205
backend/src/schemas/memo_tree.py
Normal file
205
backend/src/schemas/memo_tree.py
Normal 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
108
backend/src/schemas/todo.py
Normal 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]
|
||||
Reference in New Issue
Block a user