- 백엔드 API 완전 구현 (FastAPI + SQLAlchemy + PostgreSQL) - 사용자 인증 (JWT 토큰 기반) - 문서 CRUD (업로드, 조회, 목록, 삭제) - 하이라이트, 메모, 책갈피 관리 - 태그 시스템 및 검색 기능 - Pydantic v2 호환성 수정 - 프론트엔드 완전 구현 (Alpine.js + Tailwind CSS) - 로그인/로그아웃 기능 - 문서 업로드 모달 (드래그앤드롭, 파일 검증) - 문서 목록 및 필터링 - 뷰어 페이지 (하이라이트, 메모, 책갈피 UI) - 실시간 목록 새로고침 - 시스템 안정성 개선 - Alpine.js 컴포넌트 간 안전한 통신 (이벤트 기반) - API 오류 처리 및 사용자 피드백 - 파비콘 추가로 404 오류 해결 - 포트 구성: Frontend(24100), Backend(24102), DB(24101), Redis(24103)
301 lines
9.1 KiB
Python
301 lines
9.1 KiB
Python
"""
|
|
책갈피 관리 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"}
|