🎉 Initial commit: Document Server MVP

 Features implemented:
- FastAPI backend with JWT authentication
- PostgreSQL database with async SQLAlchemy
- HTML document viewer with smart highlighting
- Note system connected to highlights (1:1 relationship)
- Bookmark system for quick navigation
- Integrated search (documents + notes)
- Tag system for document organization
- Docker containerization with Nginx

🔧 Technical stack:
- Backend: FastAPI + PostgreSQL + Redis
- Frontend: Alpine.js + Tailwind CSS
- Authentication: JWT tokens
- File handling: HTML + PDF support
- Search: Full-text search with relevance scoring

📋 Core functionality:
- Text selection → Highlight creation
- Highlight → Note attachment
- Note management with search/filtering
- Bookmark creation at scroll positions
- Document upload with metadata
- User management (admin creates accounts)
This commit is contained in:
Hyungi Ahn
2025-08-21 16:09:17 +09:00
commit 3036b8f0fb
40 changed files with 6303 additions and 0 deletions

38
backend/Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM python:3.11-slim
# 작업 디렉토리 설정
WORKDIR /app
# 시스템 패키지 업데이트 및 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Poetry 설치
RUN pip install poetry
# Poetry 설정
ENV POETRY_NO_INTERACTION=1 \
POETRY_VENV_IN_PROJECT=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# 의존성 파일 복사
COPY pyproject.toml poetry.lock* ./
# 의존성 설치
RUN poetry install --only=main && rm -rf $POETRY_CACHE_DIR
# 애플리케이션 코드 복사
COPY src/ ./src/
# 업로드 디렉토리 생성
RUN mkdir -p /app/uploads
# 포트 노출
EXPOSE 8000
# 애플리케이션 실행
CMD ["poetry", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

35
backend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,35 @@
FROM python:3.11-slim
# 작업 디렉토리 설정
WORKDIR /app
# 시스템 패키지 업데이트 및 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Poetry 설치
RUN pip install poetry
# Poetry 설정 (개발 모드)
ENV POETRY_NO_INTERACTION=1 \
POETRY_VENV_IN_PROJECT=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# 의존성 파일 복사
COPY pyproject.toml poetry.lock* ./
# 개발 의존성 포함하여 설치
RUN poetry install && rm -rf $POETRY_CACHE_DIR
# 업로드 디렉토리 생성
RUN mkdir -p /app/uploads
# 포트 노출
EXPOSE 8000
# 개발 모드로 실행 (핫 리로드)
CMD ["poetry", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

85
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,85 @@
[tool.poetry]
name = "document-server"
version = "0.1.0"
description = "HTML Document Management and Viewer System"
authors = ["Your Name <your.email@example.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.104.0"
uvicorn = {extras = ["standard"], version = "^0.24.0"}
sqlalchemy = "^2.0.0"
asyncpg = "^0.29.0"
alembic = "^1.12.0"
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
passlib = {extras = ["bcrypt"], version = "^1.7.4"}
python-multipart = "^0.0.6"
pillow = "^10.0.0"
redis = "^5.0.0"
pydantic = {extras = ["email"], version = "^2.4.0"}
pydantic-settings = "^2.0.0"
python-dotenv = "^1.0.0"
httpx = "^0.25.0"
aiofiles = "^23.2.0"
jinja2 = "^3.1.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.4.0"
pytest-asyncio = "^0.21.0"
black = "^23.9.0"
isort = "^5.12.0"
flake8 = "^6.1.0"
mypy = "^1.6.0"
pre-commit = "^3.5.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 88
target-version = ['py311']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| build
| dist
)/
'''
[tool.isort]
profile = "black"
multi_line_output = 3
line_length = 88
known_first_party = ["src"]
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
[[tool.mypy.overrides]]
module = [
"passlib.*",
"jose.*",
"redis.*",
]
ignore_missing_imports = true

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

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

View File

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

View File

@@ -0,0 +1,88 @@
"""
API 의존성
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from src.core.database import get_db
from src.core.security import verify_token, get_user_id_from_token
from src.models.user import User
# HTTP Bearer 토큰 스키마
security = HTTPBearer()
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

View File

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

View File

@@ -0,0 +1,190 @@
"""
인증 관련 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from datetime import datetime
from src.core.database import get_db
from src.core.security import verify_password, create_access_token, create_refresh_token, get_password_hash
from src.core.config import settings
from src.models.user import User
from src.schemas.auth import (
LoginRequest, TokenResponse, RefreshTokenRequest,
UserInfo, ChangePasswordRequest, CreateUserRequest
)
from src.api.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)})
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 src.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.from_orm(current_user)
@router.put("/change-password")
async def change_password(
password_data: ChangePasswordRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""비밀번호 변경"""
# 현재 비밀번호 확인
if not verify_password(password_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password"
)
# 새 비밀번호 해싱 및 업데이트
new_hashed_password = get_password_hash(password_data.new_password)
await db.execute(
update(User)
.where(User.id == current_user.id)
.values(hashed_password=new_hashed_password)
)
await db.commit()
return {"message": "Password changed successfully"}
@router.post("/create-user", response_model=UserInfo)
async def create_user(
user_data: CreateUserRequest,
admin_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""새 사용자 생성 (관리자 전용)"""
# 이메일 중복 확인
result = await db.execute(
select(User).where(User.email == user_data.email)
)
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# 새 사용자 생성
new_user = User(
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
full_name=user_data.full_name,
is_admin=user_data.is_admin,
is_active=True
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return UserInfo.from_orm(new_user)
@router.post("/logout")
async def logout(
current_user: User = Depends(get_current_active_user)
):
"""로그아웃 (클라이언트에서 토큰 삭제)"""
# 실제로는 클라이언트에서 토큰을 삭제하면 됨
# 필요시 토큰 블랙리스트 구현 가능
return {"message": "Logged out successfully"}

View File

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

View File

@@ -0,0 +1,359 @@
"""
문서 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
import os
import uuid
import aiofiles
from pathlib import Path
from src.core.database import get_db
from src.core.config import settings
from src.models.user import User
from src.models.document import Document, Tag
from src.api.dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel
from datetime import datetime
class DocumentResponse(BaseModel):
"""문서 응답"""
id: str
title: str
description: Optional[str]
html_path: str
pdf_path: Optional[str]
thumbnail_path: Optional[str]
file_size: Optional[int]
page_count: Optional[int]
language: str
is_public: bool
is_processed: bool
created_at: datetime
updated_at: Optional[datetime]
document_date: Optional[datetime]
uploader_name: Optional[str]
tags: List[str] = []
class Config:
from_attributes = True
class TagResponse(BaseModel):
"""태그 응답"""
id: str
name: str
color: str
description: Optional[str]
document_count: int = 0
class Config:
from_attributes = True
class CreateTagRequest(BaseModel):
"""태그 생성 요청"""
name: str
color: str = "#3B82F6"
description: Optional[str] = None
router = APIRouter()
@router.get("/", response_model=List[DocumentResponse])
async def list_documents(
skip: int = 0,
limit: int = 50,
tag: Optional[str] = None,
search: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 목록 조회"""
query = select(Document).options(
selectinload(Document.uploader),
selectinload(Document.tags)
)
# 권한 필터링 (관리자가 아니면 공개 문서 + 자신이 업로드한 문서만)
if not current_user.is_admin:
query = query.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
# 태그 필터링
if tag:
query = query.join(Document.tags).where(Tag.name == tag)
# 검색 필터링
if search:
query = query.where(
or_(
Document.title.ilike(f"%{search}%"),
Document.description.ilike(f"%{search}%")
)
)
query = query.order_by(Document.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
documents = result.scalars().all()
# 응답 데이터 변환
response_data = []
for doc in documents:
doc_data = DocumentResponse.from_orm(doc)
doc_data.uploader_name = doc.uploader.full_name or doc.uploader.email
doc_data.tags = [tag.name for tag in doc.tags]
response_data.append(doc_data)
return response_data
@router.post("/", response_model=DocumentResponse)
async def upload_document(
title: str = Form(...),
description: Optional[str] = Form(None),
document_date: Optional[str] = Form(None),
is_public: bool = Form(False),
tags: Optional[str] = Form(None), # 쉼표로 구분된 태그
html_file: UploadFile = File(...),
pdf_file: Optional[UploadFile] = File(None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 업로드"""
# 파일 확장자 확인
if not html_file.filename.lower().endswith(('.html', '.htm')):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only HTML files are allowed for the main document"
)
if pdf_file and not pdf_file.filename.lower().endswith('.pdf'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only PDF files are allowed for the original document"
)
# 고유 파일명 생성
doc_id = str(uuid.uuid4())
html_filename = f"{doc_id}.html"
pdf_filename = f"{doc_id}.pdf" if pdf_file else None
# 파일 저장 경로
html_path = os.path.join(settings.UPLOAD_DIR, "documents", html_filename)
pdf_path = os.path.join(settings.UPLOAD_DIR, "documents", pdf_filename) if pdf_file else None
try:
# HTML 파일 저장
async with aiofiles.open(html_path, 'wb') as f:
content = await html_file.read()
await f.write(content)
# PDF 파일 저장 (있는 경우)
if pdf_file and pdf_path:
async with aiofiles.open(pdf_path, 'wb') as f:
content = await pdf_file.read()
await f.write(content)
# 문서 메타데이터 생성
document = Document(
id=doc_id,
title=title,
description=description,
html_path=html_path,
pdf_path=pdf_path,
file_size=len(await html_file.read()) if html_file else None,
uploaded_by=current_user.id,
original_filename=html_file.filename,
is_public=is_public,
document_date=datetime.fromisoformat(document_date) if document_date else None
)
db.add(document)
await db.flush() # ID 생성을 위해
# 태그 처리
if tags:
tag_names = [tag.strip() for tag in tags.split(',') if tag.strip()]
for tag_name in tag_names:
# 기존 태그 찾기 또는 생성
result = await db.execute(select(Tag).where(Tag.name == tag_name))
tag = result.scalar_one_or_none()
if not tag:
tag = Tag(
name=tag_name,
created_by=current_user.id
)
db.add(tag)
await db.flush()
document.tags.append(tag)
await db.commit()
await db.refresh(document)
# 응답 데이터 생성
response_data = DocumentResponse.from_orm(document)
response_data.uploader_name = current_user.full_name or current_user.email
response_data.tags = [tag.name for tag in document.tags]
return response_data
except Exception as e:
# 파일 정리
if os.path.exists(html_path):
os.remove(html_path)
if pdf_path and os.path.exists(pdf_path):
os.remove(pdf_path)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to upload document: {str(e)}"
)
@router.get("/{document_id}", response_model=DocumentResponse)
async def get_document(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 상세 조회"""
result = await db.execute(
select(Document)
.options(selectinload(Document.uploader), selectinload(Document.tags))
.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"
)
response_data = DocumentResponse.from_orm(document)
response_data.uploader_name = document.uploader.full_name or document.uploader.email
response_data.tags = [tag.name for tag in document.tags]
return response_data
@router.delete("/{document_id}")
async def delete_document(
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 document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# 파일 삭제
if document.html_path and os.path.exists(document.html_path):
os.remove(document.html_path)
if document.pdf_path and os.path.exists(document.pdf_path):
os.remove(document.pdf_path)
if document.thumbnail_path and os.path.exists(document.thumbnail_path):
os.remove(document.thumbnail_path)
# 데이터베이스에서 삭제
await db.execute(delete(Document).where(Document.id == document_id))
await db.commit()
return {"message": "Document deleted successfully"}
@router.get("/tags/", response_model=List[TagResponse])
async def list_tags(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""태그 목록 조회"""
result = await db.execute(select(Tag).order_by(Tag.name))
tags = result.scalars().all()
# 각 태그의 문서 수 계산
response_data = []
for tag in tags:
tag_data = TagResponse.from_orm(tag)
# 문서 수 계산 (권한 고려)
doc_query = select(Document).join(Document.tags).where(Tag.id == tag.id)
if not current_user.is_admin:
doc_query = doc_query.where(
or_(
Document.is_public == True,
Document.uploaded_by == current_user.id
)
)
doc_result = await db.execute(doc_query)
tag_data.document_count = len(doc_result.scalars().all())
response_data.append(tag_data)
return response_data
@router.post("/tags/", response_model=TagResponse)
async def create_tag(
tag_data: CreateTagRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""태그 생성"""
# 중복 확인
result = await db.execute(select(Tag).where(Tag.name == tag_data.name))
existing_tag = result.scalar_one_or_none()
if existing_tag:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tag already exists"
)
# 태그 생성
tag = Tag(
name=tag_data.name,
color=tag_data.color,
description=tag_data.description,
created_by=current_user.id
)
db.add(tag)
await db.commit()
await db.refresh(tag)
response_data = TagResponse.from_orm(tag)
response_data.document_count = 0
return response_data

View File

@@ -0,0 +1,340 @@
"""
하이라이트 관리 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 src.core.database import get_db
from src.models.user import User
from src.models.document import Document
from src.models.highlight import Highlight
from src.models.note import Note
from src.api.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
class HighlightResponse(BaseModel):
"""하이라이트 응답"""
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)
# 응답 데이터 생성
response_data = HighlightResponse.from_orm(highlight)
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: 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(Highlight)
.options(selectinload(Highlight.note))
.where(
and_(
Highlight.document_id == document_id,
Highlight.user_id == current_user.id
)
)
.order_by(Highlight.start_offset)
)
highlights = result.scalars().all()
# 응답 데이터 변환
response_data = []
for highlight in highlights:
highlight_data = HighlightResponse.from_orm(highlight)
if highlight.note:
highlight_data.note = {
"id": str(highlight.note.id),
"content": highlight.note.content,
"tags": highlight.note.tags,
"created_at": highlight.note.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
}
response_data.append(highlight_data)
return response_data
@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.note))
.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.from_orm(highlight)
if highlight.note:
response_data.note = {
"id": str(highlight.note.id),
"content": highlight.note.content,
"tags": highlight.note.tags,
"created_at": highlight.note.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.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.note))
.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
await db.commit()
await db.refresh(highlight)
response_data = HighlightResponse.from_orm(highlight)
if highlight.note:
response_data.note = {
"id": str(highlight.note.id),
"content": highlight.note.content,
"tags": highlight.note.tags,
"created_at": highlight.note.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.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"
)
# 하이라이트 삭제 (CASCADE로 메모도 함께 삭제됨)
await db.execute(delete(Highlight).where(Highlight.id == highlight_id))
await db.commit()
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.note)).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.from_orm(highlight)
if highlight.note:
highlight_data.note = {
"id": str(highlight.note.id),
"content": highlight.note.content,
"tags": highlight.note.tags,
"created_at": highlight.note.created_at.isoformat(),
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
}
response_data.append(highlight_data)
return response_data

View File

@@ -0,0 +1,404 @@
"""
메모 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, or_
from sqlalchemy.orm import selectinload, joinedload
from typing import List, Optional
from datetime import datetime
from src.core.database import get_db
from src.models.user import User
from src.models.highlight import Highlight
from src.models.note import Note
from src.models.document import Document
from src.api.dependencies import get_current_active_user
from pydantic import BaseModel
class CreateNoteRequest(BaseModel):
"""메모 생성 요청"""
highlight_id: str
content: str
tags: Optional[List[str]] = None
class UpdateNoteRequest(BaseModel):
"""메모 업데이트 요청"""
content: Optional[str] = None
tags: Optional[List[str]] = None
is_private: Optional[bool] = None
class NoteResponse(BaseModel):
"""메모 응답"""
id: str
highlight_id: str
content: str
is_private: bool
tags: Optional[List[str]]
created_at: datetime
updated_at: Optional[datetime]
# 연결된 하이라이트 정보
highlight: dict
# 문서 정보
document: dict
class Config:
from_attributes = True
router = APIRouter()
@router.post("/", response_model=NoteResponse)
async def create_note(
note_data: CreateNoteRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 생성 (하이라이트에 연결)"""
# 하이라이트 존재 및 소유권 확인
result = await db.execute(
select(Highlight)
.options(joinedload(Highlight.document))
.where(Highlight.id == note_data.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"
)
# 이미 메모가 있는지 확인
existing_note = await db.execute(
select(Note).where(Note.highlight_id == note_data.highlight_id)
)
if existing_note.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Note already exists for this highlight"
)
# 메모 생성
note = Note(
highlight_id=note_data.highlight_id,
content=note_data.content,
tags=note_data.tags or []
)
db.add(note)
await db.commit()
await db.refresh(note)
# 응답 데이터 생성
response_data = NoteResponse.from_orm(note)
response_data.highlight = {
"id": str(highlight.id),
"selected_text": highlight.selected_text,
"highlight_color": highlight.highlight_color,
"start_offset": highlight.start_offset,
"end_offset": highlight.end_offset
}
response_data.document = {
"id": str(highlight.document.id),
"title": highlight.document.title
}
return response_data
@router.get("/", response_model=List[NoteResponse])
async def list_user_notes(
skip: int = 0,
limit: int = 50,
document_id: Optional[str] = None,
tag: Optional[str] = None,
search: Optional[str] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""사용자의 모든 메모 조회 (검색 가능)"""
query = (
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.join(Highlight)
.where(Highlight.user_id == current_user.id)
)
# 문서 필터링
if document_id:
query = query.where(Highlight.document_id == document_id)
# 태그 필터링
if tag:
query = query.where(Note.tags.contains([tag]))
# 검색 필터링 (메모 내용 + 하이라이트된 텍스트)
if search:
query = query.where(
or_(
Note.content.ilike(f"%{search}%"),
Highlight.selected_text.ilike(f"%{search}%")
)
)
query = query.order_by(Note.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
notes = result.scalars().all()
# 응답 데이터 변환
response_data = []
for note in notes:
note_data = NoteResponse.from_orm(note)
note_data.highlight = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
note_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
response_data.append(note_data)
return response_data
@router.get("/{note_id}", response_model=NoteResponse)
async def get_note(
note_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 상세 조회"""
result = await db.execute(
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.where(Note.id == note_id)
)
note = result.scalar_one_or_none()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
# 소유자 확인
if note.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 = NoteResponse.from_orm(note)
response_data.highlight = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
response_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
return response_data
@router.put("/{note_id}", response_model=NoteResponse)
async def update_note(
note_id: str,
note_data: UpdateNoteRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 업데이트"""
result = await db.execute(
select(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.where(Note.id == note_id)
)
note = result.scalar_one_or_none()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
# 소유자 확인
if note.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 note_data.content is not None:
note.content = note_data.content
if note_data.tags is not None:
note.tags = note_data.tags
if note_data.is_private is not None:
note.is_private = note_data.is_private
await db.commit()
await db.refresh(note)
response_data = NoteResponse.from_orm(note)
response_data.highlight = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
response_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
return response_data
@router.delete("/{note_id}")
async def delete_note(
note_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 삭제 (하이라이트는 유지)"""
result = await db.execute(
select(Note)
.options(joinedload(Note.highlight))
.where(Note.id == note_id)
)
note = result.scalar_one_or_none()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
# 소유자 확인
if note.highlight.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(Note).where(Note.id == note_id))
await db.commit()
return {"message": "Note deleted successfully"}
@router.get("/document/{document_id}", response_model=List[NoteResponse])
async def get_document_notes(
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(Note)
.options(
joinedload(Note.highlight).joinedload(Highlight.document)
)
.join(Highlight)
.where(
and_(
Highlight.document_id == document_id,
Highlight.user_id == current_user.id
)
)
.order_by(Highlight.start_offset)
)
notes = result.scalars().all()
# 응답 데이터 변환
response_data = []
for note in notes:
note_data = NoteResponse.from_orm(note)
note_data.highlight = {
"id": str(note.highlight.id),
"selected_text": note.highlight.selected_text,
"highlight_color": note.highlight.highlight_color,
"start_offset": note.highlight.start_offset,
"end_offset": note.highlight.end_offset
}
note_data.document = {
"id": str(note.highlight.document.id),
"title": note.highlight.document.title
}
response_data.append(note_data)
return response_data
@router.get("/tags/popular")
async def get_popular_note_tags(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""인기 메모 태그 조회"""
# 사용자의 메모에서 태그 빈도 계산
result = await db.execute(
select(Note)
.join(Highlight)
.where(Highlight.user_id == current_user.id)
)
notes = result.scalars().all()
# 태그 빈도 계산
tag_counts = {}
for note in notes:
if note.tags:
for tag in note.tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
# 빈도순 정렬
popular_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:10]
return [{"tag": tag, "count": count} for tag, count in popular_tags]

View File

@@ -0,0 +1,354 @@
"""
검색 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 src.core.database import get_db
from src.models.user import User
from src.models.document import Document, Tag
from src.models.highlight import Highlight
from src.models.note import Note
from src.api.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, 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_notes(q, document_id, tag, current_user, db)
results.extend(note_results)
# 3. 하이라이트 검색
if not type_filter or type_filter == "highlight":
highlight_results = await search_highlights(q, document_id, current_user, db)
results.extend(highlight_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]}

View File

@@ -0,0 +1,176 @@
"""
사용자 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from typing import List
from src.core.database import get_db
from src.models.user import User
from src.schemas.auth import UserInfo
from src.api.dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel
class UpdateProfileRequest(BaseModel):
"""프로필 업데이트 요청"""
full_name: str = None
theme: str = None
language: str = None
timezone: str = None
class UpdateUserRequest(BaseModel):
"""사용자 정보 업데이트 요청 (관리자용)"""
full_name: str = None
is_active: bool = None
is_admin: bool = None
router = APIRouter()
@router.get("/profile", response_model=UserInfo)
async def get_profile(
current_user: User = Depends(get_current_active_user)
):
"""현재 사용자 프로필 조회"""
return UserInfo.from_orm(current_user)
@router.put("/profile", response_model=UserInfo)
async def update_profile(
profile_data: UpdateProfileRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""프로필 업데이트"""
update_data = {}
if profile_data.full_name is not None:
update_data["full_name"] = profile_data.full_name
if profile_data.theme is not None:
update_data["theme"] = profile_data.theme
if profile_data.language is not None:
update_data["language"] = profile_data.language
if profile_data.timezone is not None:
update_data["timezone"] = profile_data.timezone
if update_data:
await db.execute(
update(User)
.where(User.id == current_user.id)
.values(**update_data)
)
await db.commit()
await db.refresh(current_user)
return UserInfo.from_orm(current_user)
@router.get("/", response_model=List[UserInfo])
async def list_users(
admin_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 목록 조회 (관리자 전용)"""
result = await db.execute(select(User).order_by(User.created_at.desc()))
users = result.scalars().all()
return [UserInfo.from_orm(user) for user in users]
@router.get("/{user_id}", response_model=UserInfo)
async def get_user(
user_id: str,
admin_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 UserInfo.from_orm(user)
@router.put("/{user_id}", response_model=UserInfo)
async def update_user(
user_id: str,
user_data: UpdateUserRequest,
admin_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 == admin_user.id and user_data.is_admin is False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove admin privileges from yourself"
)
# 업데이트할 데이터 준비
update_data = {}
if user_data.full_name is not None:
update_data["full_name"] = user_data.full_name
if user_data.is_active is not None:
update_data["is_active"] = user_data.is_active
if user_data.is_admin is not None:
update_data["is_admin"] = user_data.is_admin
if update_data:
await db.execute(
update(User)
.where(User.id == user_id)
.values(**update_data)
)
await db.commit()
await db.refresh(user)
return UserInfo.from_orm(user)
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
admin_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 == admin_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete yourself"
)
# 사용자 삭제
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
return {"message": "User deleted successfully"}

View File

@@ -0,0 +1,52 @@
"""
애플리케이션 설정
"""
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"]
# 파일 업로드 설정
UPLOAD_DIR: str = "uploads"
MAX_FILE_SIZE: int = 100 * 1024 * 1024 # 100MB
ALLOWED_EXTENSIONS: List[str] = [".html", ".htm", ".pdf"]
# 관리자 계정 설정 (초기 설정용)
ADMIN_EMAIL: str = "admin@document-server.local"
ADMIN_PASSWORD: str = "admin123" # 프로덕션에서는 반드시 변경
class Config:
env_file = ".env"
case_sensitive = True
# 설정 인스턴스 생성
settings = Settings()
# 업로드 디렉토리 생성
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
os.makedirs(f"{settings.UPLOAD_DIR}/documents", exist_ok=True)
os.makedirs(f"{settings.UPLOAD_DIR}/thumbnails", exist_ok=True)

View File

@@ -0,0 +1,94 @@
"""
데이터베이스 설정 및 연결
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import MetaData
from typing import AsyncGenerator
from src.core.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,
)
# 비동기 세션 팩토리
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
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()
async def init_db() -> None:
"""데이터베이스 초기화"""
from src.models import user, document, highlight, note, bookmark, tag
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 src.models.user import User
from src.core.security import get_password_hash
from sqlalchemy import select
async with AsyncSessionLocal() as session:
# 관리자 계정 존재 확인
result = await session.execute(
select(User).where(User.email == settings.ADMIN_EMAIL)
)
admin_user = result.scalar_one_or_none()
if not admin_user:
# 관리자 계정 생성
admin_user = User(
email=settings.ADMIN_EMAIL,
hashed_password=get_password_hash(settings.ADMIN_PASSWORD),
is_active=True,
is_admin=True,
full_name="Administrator"
)
session.add(admin_user)
await session.commit()
print(f"관리자 계정이 생성되었습니다: {settings.ADMIN_EMAIL}")

View File

@@ -0,0 +1,87 @@
"""
보안 관련 유틸리티
"""
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 src.core.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) -> str:
"""액세스 토큰 생성"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
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

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

@@ -0,0 +1,72 @@
"""
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 src.core.config import settings
from src.core.database import init_db
from src.api.routes import auth, users, documents, highlights, notes, bookmarks, search
@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_HOSTS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 정적 파일 서빙 (업로드된 파일들)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# API 라우터 등록
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/notes", tags=["메모"])
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
app.include_router(search.router, prefix="/api/search", tags=["검색"])
@app.get("/")
async def root():
"""루트 엔드포인트"""
return {"message": "Document Server API", "version": "0.1.0"}
@app.get("/health")
async def health_check():
"""헬스체크 엔드포인트"""
return {"status": "healthy"}
if __name__ == "__main__":
uvicorn.run(
"src.main:app",
host="0.0.0.0",
port=8000,
reload=True if settings.DEBUG else False,
)

View File

@@ -0,0 +1,17 @@
"""
모델 패키지 초기화
"""
from src.models.user import User
from src.models.document import Document, Tag
from src.models.highlight import Highlight
from src.models.note import Note
from src.models.bookmark import Bookmark
__all__ = [
"User",
"Document",
"Tag",
"Highlight",
"Note",
"Bookmark",
]

View File

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

View File

@@ -0,0 +1,81 @@
"""
문서 모델
"""
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 src.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)
title = Column(String(500), nullable=False, index=True)
description = Column(Text, nullable=True)
# 파일 정보
html_path = Column(String(1000), nullable=False) # HTML 파일 경로
pdf_path = Column(String(1000), nullable=True) # PDF 원본 경로 (선택)
thumbnail_path = Column(String(1000), nullable=True) # 썸네일 경로
# 메타데이터
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) # 문서 작성일 (사용자 입력)
# 관계
uploader = relationship("User", backref="uploaded_documents")
tags = relationship("Tag", secondary=document_tags, back_populates="documents")
highlights = relationship("Highlight", back_populates="document", cascade="all, delete-orphan")
bookmarks = relationship("Bookmark", back_populates="document", cascade="all, delete-orphan")
def __repr__(self):
return f"<Document(title='{self.title}', id='{self.id}')>"
class Tag(Base):
"""태그 테이블"""
__tablename__ = "tags"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), unique=True, nullable=False, index=True)
color = Column(String(7), default="#3B82F6") # HEX 색상 코드
description = Column(Text, nullable=True)
# 메타데이터
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# 관계
creator = relationship("User", backref="created_tags")
documents = relationship("Document", secondary=document_tags, back_populates="tags")
def __repr__(self):
return f"<Tag(name='{self.name}', color='{self.color}')>"

View File

@@ -0,0 +1,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 src.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")
note = relationship("Note", back_populates="highlight", uselist=False, cascade="all, delete-orphan")
def __repr__(self):
return f"<Highlight(id='{self.id}', text='{self.selected_text[:50]}...')>"

View File

@@ -0,0 +1,47 @@
"""
메모 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from src.core.database import Base
class Note(Base):
"""메모 테이블 (하이라이트와 1:1 관계)"""
__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, unique=True)
# 메모 내용
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="note")
@property
def user_id(self):
"""하이라이트를 통해 사용자 ID 가져오기"""
return self.highlight.user_id if self.highlight else None
@property
def document_id(self):
"""하이라이트를 통해 문서 ID 가져오기"""
return self.highlight.document_id if self.highlight else None
def __repr__(self):
return f"<Note(id='{self.id}', content='{self.content[:50]}...')>"

View File

@@ -0,0 +1,34 @@
"""
사용자 모델
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from src.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)
# 메타데이터
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")
def __repr__(self):
return f"<User(email='{self.email}', full_name='{self.full_name}')>"

View File

@@ -0,0 +1,56 @@
"""
인증 관련 스키마
"""
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
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: str
email: str
full_name: Optional[str] = None
is_active: bool
is_admin: bool
theme: str
language: str
timezone: str
created_at: datetime
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