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

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

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

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

52
backend/Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
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 대신 pip 사용)
RUN pip install --no-cache-dir \
fastapi==0.104.1 \
uvicorn[standard]==0.24.0 \
sqlalchemy==2.0.23 \
asyncpg==0.29.0 \
psycopg2-binary==2.9.7 \
alembic==1.12.1 \
python-jose[cryptography]==3.3.0 \
passlib[bcrypt]==1.7.4 \
python-multipart==0.0.6 \
pillow==10.1.0 \
redis==5.0.1 \
pydantic[email]==2.5.0 \
pydantic-settings==2.1.0 \
python-dotenv==1.0.0 \
httpx==0.25.2 \
aiofiles==23.2.1 \
jinja2==3.1.2 \
greenlet==3.0.0
# 애플리케이션 코드 복사
COPY src/ ./src/
# 업로드 디렉토리 생성
RUN mkdir -p /app/uploads
# 환경변수 설정
ENV PYTHONPATH=/app
ENV DATABASE_URL=postgresql+asyncpg://docuser:docpass@database:5432/document_db
ENV SECRET_KEY=production-secret-key-change-this
ENV ADMIN_EMAIL=admin@test.com
ENV ADMIN_PASSWORD=admin123
# 포트 노출
EXPOSE 8000
# 애플리케이션 실행 (직접 uvicorn 실행)
CMD ["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"]

View File

@@ -0,0 +1,153 @@
-- 트리 구조 메모장 테이블 생성
-- 005_create_memo_tree_tables.sql
-- 메모 트리 (프로젝트/워크스페이스)
CREATE TABLE memo_trees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general'
template_data JSONB, -- 템플릿별 메타데이터
settings JSONB DEFAULT '{}', -- 트리별 설정
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_public BOOLEAN DEFAULT FALSE,
is_archived BOOLEAN DEFAULT FALSE
);
-- 메모 노드 (트리의 각 노드)
CREATE TABLE memo_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 기본 정보
title VARCHAR(500) NOT NULL,
content TEXT, -- 실제 메모 내용 (Markdown)
node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot'
-- 트리 구조 관리
sort_order INTEGER DEFAULT 0,
depth_level INTEGER DEFAULT 0,
path TEXT, -- 경로 저장 (예: /1/3/7)
-- 메타데이터
tags TEXT[], -- 태그 배열
node_metadata JSONB DEFAULT '{}', -- 노드별 메타데이터 (캐릭터 정보, 플롯 정보 등)
-- 상태 관리
status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete'
word_count INTEGER DEFAULT 0,
-- 시간 정보
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- 제약 조건
CONSTRAINT no_self_reference CHECK (id != parent_id)
);
-- 메모 노드 버전 관리 (선택적)
CREATE TABLE memo_node_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
title VARCHAR(500) NOT NULL,
content TEXT,
node_metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(node_id, version_number)
);
-- 메모 트리 공유 (협업 기능)
CREATE TABLE memo_tree_shares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE,
shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin'
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(tree_id, shared_with_user_id)
);
-- 인덱스 생성
CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id);
CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type);
CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id);
CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id);
CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id);
CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/'));
CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags);
CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type);
CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id);
CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id);
-- 트리거 함수: updated_at 자동 업데이트
CREATE OR REPLACE FUNCTION update_memo_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 트리거 생성
CREATE TRIGGER memo_trees_updated_at
BEFORE UPDATE ON memo_trees
FOR EACH ROW
EXECUTE FUNCTION update_memo_updated_at();
CREATE TRIGGER memo_nodes_updated_at
BEFORE UPDATE ON memo_nodes
FOR EACH ROW
EXECUTE FUNCTION update_memo_updated_at();
-- 트리거 함수: 경로 자동 업데이트
CREATE OR REPLACE FUNCTION update_memo_node_path()
RETURNS TRIGGER AS $$
BEGIN
-- 루트 노드인 경우
IF NEW.parent_id IS NULL THEN
NEW.path = '/' || NEW.id::text;
NEW.depth_level = 0;
ELSE
-- 부모 노드의 경로를 가져와서 확장
SELECT path || '/' || NEW.id::text, depth_level + 1
INTO NEW.path, NEW.depth_level
FROM memo_nodes
WHERE id = NEW.parent_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 경로 업데이트 트리거
CREATE TRIGGER memo_nodes_path_update
BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes
FOR EACH ROW
EXECUTE FUNCTION update_memo_node_path();
-- 샘플 데이터 (개발용)
-- 소설 템플릿 예시
INSERT INTO memo_trees (user_id, title, description, tree_type, template_data)
SELECT
u.id,
'내 첫 번째 소설',
'판타지 소설 프로젝트',
'novel',
'{
"genre": "fantasy",
"target_length": 100000,
"chapters_planned": 20,
"main_characters": [],
"world_building": {}
}'::jsonb
FROM users u
WHERE u.email = 'admin@test.com'
LIMIT 1;

View File

@@ -0,0 +1,98 @@
-- 006_add_canonical_path.sql
-- 정사 경로 표시를 위한 필드 추가
-- memo_nodes 테이블에 정사 경로 관련 필드 추가
ALTER TABLE memo_nodes
ADD COLUMN is_canonical BOOLEAN DEFAULT FALSE,
ADD COLUMN canonical_order INTEGER DEFAULT NULL,
ADD COLUMN story_path TEXT DEFAULT NULL; -- 정사 경로 저장 (예: /1/3/7)
-- 정사 경로 순서를 위한 인덱스 추가
CREATE INDEX idx_memo_nodes_canonical_order ON memo_nodes(tree_id, canonical_order) WHERE is_canonical = TRUE;
-- 트리별 정사 경로 통계를 위한 뷰 생성
CREATE OR REPLACE VIEW memo_tree_canonical_stats AS
SELECT
t.id as tree_id,
t.title as tree_title,
COUNT(n.id) as total_nodes,
COUNT(CASE WHEN n.is_canonical = TRUE THEN 1 END) as canonical_nodes,
MAX(n.canonical_order) as max_canonical_order,
STRING_AGG(
CASE WHEN n.is_canonical = TRUE THEN n.title END,
''
ORDER BY n.canonical_order
) as canonical_story_path
FROM memo_trees t
LEFT JOIN memo_nodes n ON t.id = n.tree_id
GROUP BY t.id, t.title;
-- 정사 경로 순서 자동 업데이트 함수 (분기점에서 하나만 선택 가능)
CREATE OR REPLACE FUNCTION update_canonical_order()
RETURNS TRIGGER AS $$
BEGIN
-- 정사로 설정될 때
IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN
-- 같은 부모를 가진 다른 형제 노드들의 정사 상태 해제 (분기점에서 하나만 선택)
IF NEW.parent_id IS NOT NULL THEN
UPDATE memo_nodes
SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL
WHERE tree_id = NEW.tree_id
AND parent_id = NEW.parent_id
AND id != NEW.id
AND is_canonical = TRUE;
END IF;
-- 부모 노드의 순서를 기준으로 순서 계산
IF NEW.parent_id IS NULL THEN
-- 루트 노드는 항상 1
NEW.canonical_order = 1;
ELSE
-- 부모 노드의 순서 + 1
SELECT COALESCE(parent.canonical_order, 0) + 1
INTO NEW.canonical_order
FROM memo_nodes parent
WHERE parent.id = NEW.parent_id AND parent.is_canonical = TRUE;
-- 부모가 정사가 아니면 순서 할당 안함
IF NEW.canonical_order IS NULL THEN
NEW.canonical_order = NULL;
END IF;
END IF;
-- 정사 경로 업데이트
NEW.story_path = COALESCE(NEW.path, '');
END IF;
-- 정사에서 제외될 때 순서 제거
IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN
NEW.canonical_order = NULL;
NEW.story_path = NULL;
-- 뒤의 순서들을 앞으로 당기기
UPDATE memo_nodes
SET canonical_order = canonical_order - 1
WHERE tree_id = NEW.tree_id
AND is_canonical = TRUE
AND canonical_order > OLD.canonical_order;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 트리거 생성
DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes;
CREATE TRIGGER trigger_update_canonical_order
BEFORE UPDATE ON memo_nodes
FOR EACH ROW
EXECUTE FUNCTION update_canonical_order();
-- 기존 루트 노드들을 정사로 설정 (기본값)
UPDATE memo_nodes
SET is_canonical = TRUE, canonical_order = 1
WHERE parent_id IS NULL AND is_canonical = FALSE;
COMMENT ON COLUMN memo_nodes.is_canonical IS '정사 경로 여부 (소설의 메인 스토리라인)';
COMMENT ON COLUMN memo_nodes.canonical_order IS '정사 경로에서의 순서 (1부터 시작)';
COMMENT ON COLUMN memo_nodes.story_path IS '정사 경로 문자열 표현';

View File

@@ -0,0 +1,58 @@
-- 할일관리 시스템 테이블 생성
-- 할일 아이템 테이블
CREATE TABLE IF NOT EXISTS todo_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 기본 정보
content TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'active', 'completed', 'delayed', 'split')),
-- 시간 관리
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
start_date TIMESTAMP WITH TIME ZONE,
estimated_minutes INTEGER CHECK (estimated_minutes > 0 AND estimated_minutes <= 120),
completed_at TIMESTAMP WITH TIME ZONE,
delayed_until TIMESTAMP WITH TIME ZONE,
-- 분할 관리
parent_id UUID REFERENCES todo_items(id) ON DELETE CASCADE,
split_order INTEGER,
-- 인덱스
CONSTRAINT unique_split_order UNIQUE (parent_id, split_order)
);
-- 할일 댓글 테이블
CREATE TABLE IF NOT EXISTS todo_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
todo_item_id UUID NOT NULL REFERENCES todo_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_todo_items_user_id ON todo_items(user_id);
CREATE INDEX IF NOT EXISTS idx_todo_items_status ON todo_items(status);
CREATE INDEX IF NOT EXISTS idx_todo_items_start_date ON todo_items(start_date);
CREATE INDEX IF NOT EXISTS idx_todo_items_parent_id ON todo_items(parent_id);
CREATE INDEX IF NOT EXISTS idx_todo_comments_todo_item_id ON todo_comments(todo_item_id);
CREATE INDEX IF NOT EXISTS idx_todo_comments_user_id ON todo_comments(user_id);
-- 트리거: updated_at 자동 업데이트
CREATE OR REPLACE FUNCTION update_todo_comments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_todo_comments_updated_at
BEFORE UPDATE ON todo_comments
FOR EACH ROW
EXECUTE FUNCTION update_todo_comments_updated_at();

View File

@@ -0,0 +1,97 @@
-- 007_fix_canonical_order.sql
-- 정사 경로 순서 계산 로직 수정
-- 기존 트리거 삭제
DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes;
DROP FUNCTION IF EXISTS update_canonical_order();
-- 정사 경로 순서를 올바르게 계산하는 함수
CREATE OR REPLACE FUNCTION update_canonical_order()
RETURNS TRIGGER AS $$
BEGIN
-- 정사로 설정될 때
IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN
-- 같은 부모를 가진 다른 형제 노드들의 정사 상태 해제 (분기점에서 하나만 선택)
IF NEW.parent_id IS NOT NULL THEN
UPDATE memo_nodes
SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL
WHERE tree_id = NEW.tree_id
AND parent_id = NEW.parent_id
AND id != NEW.id
AND is_canonical = TRUE;
END IF;
-- 정사 경로 업데이트
NEW.story_path = COALESCE(NEW.path, '');
-- 순서는 별도 함수에서 일괄 계산
PERFORM recalculate_canonical_orders(NEW.tree_id);
END IF;
-- 정사에서 제외될 때
IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN
NEW.canonical_order = NULL;
NEW.story_path = NULL;
-- 순서 재계산
PERFORM recalculate_canonical_orders(NEW.tree_id);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 트리별 정사 경로 순서를 DFS로 재계산하는 함수
CREATE OR REPLACE FUNCTION recalculate_canonical_orders(tree_uuid UUID)
RETURNS VOID AS $$
DECLARE
current_order INTEGER := 1;
BEGIN
-- 모든 정사 노드의 순서를 NULL로 초기화
UPDATE memo_nodes
SET canonical_order = NULL
WHERE tree_id = tree_uuid AND is_canonical = TRUE;
-- DFS로 순서 할당 (재귀 CTE 사용)
WITH RECURSIVE canonical_path AS (
-- 루트 노드들 (정사인 것만)
SELECT id, parent_id, title, 1 as order_num, ARRAY[id] as path
FROM memo_nodes
WHERE tree_id = tree_uuid
AND parent_id IS NULL
AND is_canonical = TRUE
UNION ALL
-- 자식 노드들 (정사인 것만)
SELECT n.id, n.parent_id, n.title,
cp.order_num + 1 as order_num,
cp.path || n.id
FROM memo_nodes n
INNER JOIN canonical_path cp ON n.parent_id = cp.id
WHERE n.tree_id = tree_uuid
AND n.is_canonical = TRUE
)
UPDATE memo_nodes
SET canonical_order = cp.order_num
FROM canonical_path cp
WHERE memo_nodes.id = cp.id;
END;
$$ LANGUAGE plpgsql;
-- 트리거 다시 생성
CREATE TRIGGER trigger_update_canonical_order
AFTER UPDATE ON memo_nodes
FOR EACH ROW
EXECUTE FUNCTION update_canonical_order();
-- 기존 데이터의 순서 재계산
DO $$
DECLARE
tree_rec RECORD;
BEGIN
FOR tree_rec IN SELECT DISTINCT tree_id FROM memo_nodes WHERE is_canonical = TRUE
LOOP
PERFORM recalculate_canonical_orders(tree_rec.tree_id);
END LOOP;
END $$;

View File

@@ -0,0 +1,50 @@
-- 서적 테이블 및 관계 추가
-- 2025-08-22: 서적 그룹화 기능 추가
-- 서적 테이블 생성
CREATE TABLE IF NOT EXISTS books (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
author VARCHAR(200),
publisher VARCHAR(200),
isbn VARCHAR(20) UNIQUE,
description TEXT,
language VARCHAR(10) DEFAULT 'ko',
total_pages INTEGER DEFAULT 0,
cover_image_path VARCHAR(500),
is_public BOOLEAN DEFAULT true,
tags VARCHAR(1000),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_books_title ON books(title);
CREATE INDEX IF NOT EXISTS idx_books_author ON books(author);
CREATE INDEX IF NOT EXISTS idx_books_created_at ON books(created_at);
-- documents 테이블에 book_id 컬럼 추가
ALTER TABLE documents ADD COLUMN IF NOT EXISTS book_id UUID;
-- 외래키 제약조건 추가
ALTER TABLE documents ADD CONSTRAINT IF NOT EXISTS fk_documents_book_id
FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE SET NULL;
-- book_id 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_documents_book_id ON documents(book_id);
-- 업데이트 트리거 함수 생성 (updated_at 자동 업데이트)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- books 테이블에 업데이트 트리거 추가
DROP TRIGGER IF EXISTS update_books_updated_at ON books;
CREATE TRIGGER update_books_updated_at
BEFORE UPDATE ON books
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,12 @@
-- 문서에 PDF 매칭 필드 추가
-- Migration: 005_add_matched_pdf_id.sql
-- matched_pdf_id 컬럼 추가
ALTER TABLE documents
ADD COLUMN matched_pdf_id UUID REFERENCES documents(id);
-- 인덱스 추가 (성능 향상)
CREATE INDEX idx_documents_matched_pdf_id ON documents(matched_pdf_id);
-- 코멘트 추가
COMMENT ON COLUMN documents.matched_pdf_id IS '매칭된 PDF 문서 ID (HTML 문서에 연결된 원본 PDF)';

View File

@@ -0,0 +1,9 @@
-- HTML 경로를 nullable로 변경 (PDF만 업로드하는 경우 대응)
-- Migration: 006_make_html_path_nullable.sql
-- html_path 컬럼을 nullable로 변경
ALTER TABLE documents
ALTER COLUMN html_path DROP NOT NULL;
-- 코멘트 업데이트
COMMENT ON COLUMN documents.html_path IS 'HTML 파일 경로 (PDF만 업로드하는 경우 null 가능)';

View File

@@ -0,0 +1,34 @@
-- 문서 링크 테이블 생성
CREATE TABLE document_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
target_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
selected_text TEXT NOT NULL,
start_offset INTEGER NOT NULL,
end_offset INTEGER NOT NULL,
link_text VARCHAR(500),
description TEXT,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE
);
-- 인덱스 생성
CREATE INDEX idx_document_links_source_document_id ON document_links(source_document_id);
CREATE INDEX idx_document_links_target_document_id ON document_links(target_document_id);
CREATE INDEX idx_document_links_created_by ON document_links(created_by);
CREATE INDEX idx_document_links_start_offset ON document_links(start_offset);
-- 업데이트 트리거 생성
CREATE OR REPLACE FUNCTION update_document_links_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_document_links_updated_at
BEFORE UPDATE ON document_links
FOR EACH ROW
EXECUTE FUNCTION update_document_links_updated_at();

View File

@@ -0,0 +1,24 @@
-- 문서 링크 테이블에 고급 기능을 위한 컬럼 추가
-- 도착점 텍스트 정보 컬럼 추가
ALTER TABLE document_links
ADD COLUMN target_text TEXT,
ADD COLUMN target_start_offset INTEGER,
ADD COLUMN target_end_offset INTEGER;
-- 링크 타입 컬럼 추가 (기본값: document)
ALTER TABLE document_links
ADD COLUMN link_type VARCHAR(20) DEFAULT 'document' NOT NULL;
-- 기존 데이터의 link_type을 'document'로 설정 (이미 기본값이지만 명시적으로)
UPDATE document_links SET link_type = 'document' WHERE link_type IS NULL;
-- 인덱스 추가 (성능 향상)
CREATE INDEX idx_document_links_link_type ON document_links(link_type);
CREATE INDEX idx_document_links_target_offset ON document_links(target_document_id, target_start_offset, target_end_offset);
-- 코멘트 추가
COMMENT ON COLUMN document_links.target_text IS '대상 문서에서 선택된 텍스트';
COMMENT ON COLUMN document_links.target_start_offset IS '대상 문서에서 텍스트 시작 위치';
COMMENT ON COLUMN document_links.target_end_offset IS '대상 문서에서 텍스트 끝 위치';
COMMENT ON COLUMN document_links.link_type IS '링크 타입: document(전체 문서) 또는 text_fragment(특정 텍스트 부분)';

View File

@@ -0,0 +1,81 @@
-- 노트 관리 시스템 생성
-- 009_create_notes_system.sql
-- 노트 문서 테이블
CREATE TABLE IF NOT EXISTS notes_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
content TEXT, -- 마크다운 내용
html_content TEXT, -- 변환된 HTML 내용
note_type VARCHAR(50) DEFAULT 'note', -- note, research, summary, idea 등
tags TEXT[] DEFAULT '{}', -- 태그 배열
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(100) NOT NULL,
is_published BOOLEAN DEFAULT false, -- 공개 여부
parent_note_id UUID REFERENCES notes_documents(id) ON DELETE SET NULL, -- 계층 구조
sort_order INTEGER DEFAULT 0, -- 정렬 순서
word_count INTEGER DEFAULT 0, -- 단어 수
reading_time INTEGER DEFAULT 0, -- 예상 읽기 시간 (분)
-- 인덱스
CONSTRAINT notes_documents_title_check CHECK (char_length(title) > 0)
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_notes_documents_created_by ON notes_documents(created_by);
CREATE INDEX IF NOT EXISTS idx_notes_documents_created_at ON notes_documents(created_at);
CREATE INDEX IF NOT EXISTS idx_notes_documents_note_type ON notes_documents(note_type);
CREATE INDEX IF NOT EXISTS idx_notes_documents_parent_note_id ON notes_documents(parent_note_id);
CREATE INDEX IF NOT EXISTS idx_notes_documents_tags ON notes_documents USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_notes_documents_is_published ON notes_documents(is_published);
-- 업데이트 시간 자동 갱신 트리거
CREATE OR REPLACE FUNCTION update_notes_documents_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_notes_documents_updated_at
BEFORE UPDATE ON notes_documents
FOR EACH ROW
EXECUTE FUNCTION update_notes_documents_updated_at();
-- 기존 document_links 테이블에 노트 지원 추가
-- (이미 존재하는 테이블이므로 ALTER 사용)
DO $$
BEGIN
-- source_type, target_type 컬럼이 없다면 추가
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'document_links' AND column_name = 'source_type'
) THEN
ALTER TABLE document_links
ADD COLUMN source_type VARCHAR(20) DEFAULT 'document',
ADD COLUMN target_type VARCHAR(20) DEFAULT 'document';
-- 기존 데이터는 모두 'document' 타입으로 설정
UPDATE document_links SET source_type = 'document', target_type = 'document';
END IF;
END $$;
-- 노트 관련 링크를 위한 인덱스
CREATE INDEX IF NOT EXISTS idx_document_links_source_type ON document_links(source_type);
CREATE INDEX IF NOT EXISTS idx_document_links_target_type ON document_links(target_type);
-- 샘플 노트 타입 데이터
INSERT INTO notes_documents (title, content, html_content, note_type, tags, created_by, is_published)
VALUES
('노트 시스템 사용법',
'# 노트 시스템 사용법\n\n## 기본 기능\n- 마크다운으로 노트 작성\n- HTML로 자동 변환\n- 태그 기반 분류\n\n## 고급 기능\n- 서적과 링크 연결\n- 계층 구조 지원\n- 내보내기 기능',
'<h1>노트 시스템 사용법</h1><h2>기본 기능</h2><ul><li>마크다운으로 노트 작성</li><li>HTML로 자동 변환</li><li>태그 기반 분류</li></ul><h2>고급 기능</h2><ul><li>서적과 링크 연결</li><li>계층 구조 지원</li><li>내보내기 기능</li></ul>',
'guide',
ARRAY['가이드', '사용법', '시스템'],
'Administrator',
true)
ON CONFLICT DO NOTHING;
COMMIT;

View File

@@ -0,0 +1,25 @@
-- 노트북 시스템 생성
CREATE TABLE notebooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
description TEXT,
color VARCHAR(7) DEFAULT '#3B82F6', -- 헥스 컬러 코드
icon VARCHAR(50) DEFAULT 'book', -- FontAwesome 아이콘 이름
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by VARCHAR(100) NOT NULL,
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0
);
-- 노트북-노트 관계 테이블 (기존 notes_documents의 parent_note_id 대신 사용)
ALTER TABLE notes_documents ADD COLUMN notebook_id UUID REFERENCES notebooks(id);
-- 인덱스 생성
CREATE INDEX idx_notebooks_created_by ON notebooks(created_by);
CREATE INDEX idx_notebooks_created_at ON notebooks(created_at);
CREATE INDEX idx_notes_notebook_id ON notes_documents(notebook_id);
-- 기본 노트북 생성 (기존 노트들을 위한)
INSERT INTO notebooks (title, description, created_by, color, icon)
VALUES ('기본 노트북', '분류되지 않은 노트들', 'admin@test.com', '#6B7280', 'sticky-note');

View File

@@ -0,0 +1,48 @@
-- 노트용 하이라이트 테이블 생성
CREATE TABLE note_highlights (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
note_id UUID NOT NULL REFERENCES notes_documents(id) ON DELETE CASCADE,
start_offset INTEGER NOT NULL,
end_offset INTEGER NOT NULL,
selected_text TEXT NOT NULL,
highlight_color VARCHAR(50) NOT NULL DEFAULT '#FFFF00',
highlight_type VARCHAR(50) NOT NULL DEFAULT 'highlight',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(100) NOT NULL
);
-- 노트용 메모 테이블 생성
CREATE TABLE note_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
note_id UUID NOT NULL REFERENCES notes_documents(id) ON DELETE CASCADE,
highlight_id UUID REFERENCES note_highlights(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(100) NOT NULL
);
-- 인덱스 생성
CREATE INDEX ix_note_highlights_note_id ON note_highlights (note_id);
CREATE INDEX ix_note_highlights_created_by ON note_highlights (created_by);
CREATE INDEX ix_note_notes_note_id ON note_notes (note_id);
CREATE INDEX ix_note_notes_highlight_id ON note_notes (highlight_id);
CREATE INDEX ix_note_notes_created_by ON note_notes (created_by);
-- updated_at 자동 업데이트 트리거
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_note_highlights_updated_at
BEFORE UPDATE ON note_highlights
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_note_notes_updated_at
BEFORE UPDATE ON note_notes
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,75 @@
-- 노트 링크 테이블 생성
-- 노트 문서 간 또는 노트-문서 간 링크를 관리하는 테이블
CREATE TABLE IF NOT EXISTS note_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- 링크 출발점 (노트 또는 문서 중 하나)
source_note_id UUID REFERENCES notes_documents(id) ON DELETE CASCADE,
source_document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
-- 링크 도착점 (노트 또는 문서 중 하나)
target_note_id UUID REFERENCES notes_documents(id) ON DELETE CASCADE,
target_document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
-- 출발점 텍스트 정보
selected_text TEXT NOT NULL,
start_offset INTEGER NOT NULL,
end_offset INTEGER NOT NULL,
-- 도착점 텍스트 정보 (선택사항)
target_text TEXT,
target_start_offset INTEGER,
target_end_offset INTEGER,
-- 링크 메타데이터
link_text VARCHAR(500),
description TEXT,
link_type VARCHAR(20) DEFAULT 'note' NOT NULL,
-- 생성자 및 시간 정보
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE,
-- 제약 조건
CONSTRAINT note_links_source_check CHECK (
(source_note_id IS NOT NULL AND source_document_id IS NULL) OR
(source_note_id IS NULL AND source_document_id IS NOT NULL)
),
CONSTRAINT note_links_target_check CHECK (
(target_note_id IS NOT NULL AND target_document_id IS NULL) OR
(target_note_id IS NULL AND target_document_id IS NOT NULL)
)
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_note_links_source_note ON note_links(source_note_id);
CREATE INDEX IF NOT EXISTS idx_note_links_source_document ON note_links(source_document_id);
CREATE INDEX IF NOT EXISTS idx_note_links_target_note ON note_links(target_note_id);
CREATE INDEX IF NOT EXISTS idx_note_links_target_document ON note_links(target_document_id);
CREATE INDEX IF NOT EXISTS idx_note_links_created_by ON note_links(created_by);
CREATE INDEX IF NOT EXISTS idx_note_links_created_at ON note_links(created_at);
-- updated_at 자동 업데이트 트리거
CREATE OR REPLACE FUNCTION update_note_links_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_note_links_updated_at
BEFORE UPDATE ON note_links
FOR EACH ROW
EXECUTE FUNCTION update_note_links_updated_at();
-- 코멘트 추가
COMMENT ON TABLE note_links IS '노트 문서 간 링크 관리 테이블';
COMMENT ON COLUMN note_links.source_note_id IS '출발점 노트 ID (노트에서 시작하는 링크)';
COMMENT ON COLUMN note_links.source_document_id IS '출발점 문서 ID (문서에서 시작하는 링크)';
COMMENT ON COLUMN note_links.target_note_id IS '도착점 노트 ID';
COMMENT ON COLUMN note_links.target_document_id IS '도착점 문서 ID';
COMMENT ON COLUMN note_links.link_type IS '링크 타입: note, document, text_fragment';

87
backend/pyproject.toml Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,155 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update
from typing import List
from uuid import UUID
from ...core.database import get_db
from ..dependencies import get_current_active_user
from ...models.user import User
from ...models.book import Book
from ...models.book_category import BookCategory
from ...models.document import Document
from ...schemas.book_category import (
CreateBookCategoryRequest,
UpdateBookCategoryRequest,
BookCategoryResponse,
UpdateDocumentOrderRequest
)
router = APIRouter()
@router.post("/", response_model=BookCategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_book_category(
category_data: CreateBookCategoryRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새로운 서적 소분류 생성"""
# 서적 존재 확인
book_result = await db.execute(select(Book).where(Book.id == category_data.book_id))
book = book_result.scalar_one_or_none()
if not book:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
# 권한 확인 (관리자만)
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can create categories")
new_category = BookCategory(**category_data.model_dump())
db.add(new_category)
await db.commit()
await db.refresh(new_category)
return await _get_category_response(db, new_category)
@router.get("/book/{book_id}", response_model=List[BookCategoryResponse])
async def get_book_categories(
book_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 서적의 소분류 목록 조회"""
result = await db.execute(
select(BookCategory)
.where(BookCategory.book_id == book_id)
.order_by(BookCategory.sort_order, BookCategory.name)
)
categories = result.scalars().all()
response_categories = []
for category in categories:
response_categories.append(await _get_category_response(db, category))
return response_categories
@router.put("/{category_id}", response_model=BookCategoryResponse)
async def update_book_category(
category_id: UUID,
category_data: UpdateBookCategoryRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""서적 소분류 수정"""
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update categories")
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
for field, value in category_data.model_dump(exclude_unset=True).items():
setattr(category, field, value)
await db.commit()
await db.refresh(category)
return await _get_category_response(db, category)
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book_category(
category_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""서적 소분류 삭제 (포함된 문서들은 미분류로 이동)"""
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete categories")
result = await db.execute(select(BookCategory).where(BookCategory.id == category_id))
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
# 포함된 문서들을 미분류로 이동 (category_id를 NULL로 설정)
await db.execute(
update(Document)
.where(Document.category_id == category_id)
.values(category_id=None)
)
await db.delete(category)
await db.commit()
return {"message": "Category deleted successfully"}
@router.put("/documents/reorder", status_code=status.HTTP_200_OK)
async def update_document_order(
order_data: UpdateDocumentOrderRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 순서 변경"""
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can reorder documents")
# 문서 순서 업데이트
for item in order_data.document_orders:
document_id = item.get("document_id")
sort_order = item.get("sort_order", 0)
await db.execute(
update(Document)
.where(Document.id == document_id)
.values(sort_order=sort_order)
)
await db.commit()
return {"message": "Document order updated successfully"}
# Helper function
async def _get_category_response(db: AsyncSession, category: BookCategory) -> BookCategoryResponse:
"""BookCategory를 BookCategoryResponse로 변환"""
document_count_result = await db.execute(
select(func.count(Document.id)).where(Document.category_id == category.id)
)
document_count = document_count_result.scalar_one()
return BookCategoryResponse(
id=category.id,
book_id=category.book_id,
name=category.name,
description=category.description,
sort_order=category.sort_order,
created_at=category.created_at,
updated_at=category.updated_at,
document_count=document_count
)

View File

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

View File

@@ -0,0 +1,230 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from uuid import UUID
import difflib # For similarity suggestions
from ...core.database import get_db
from ..dependencies import get_current_active_user
from ...models.user import User
from ...models.book import Book
from ...models.document import Document
from ...schemas.book import CreateBookRequest, UpdateBookRequest, BookResponse, BookSearchResponse, BookSuggestionResponse
router = APIRouter()
# Helper to convert Book ORM object to BookResponse
async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse:
document_count_result = await db.execute(
select(func.count(Document.id)).where(Document.book_id == book.id)
)
document_count = document_count_result.scalar_one()
return BookResponse(
id=book.id,
title=book.title,
author=book.author,
description=book.description,
language=book.language,
is_public=book.is_public,
created_at=book.created_at,
updated_at=book.updated_at,
document_count=document_count
)
@router.post("", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
async def create_book(
book_data: CreateBookRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새로운 서적 생성"""
# Check if a book with the same title and author already exists for the user
existing_book_query = select(Book).where(Book.title == book_data.title)
if book_data.author:
existing_book_query = existing_book_query.where(Book.author == book_data.author)
existing_book = await db.execute(existing_book_query)
if existing_book.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A book with this title and author already exists."
)
new_book = Book(**book_data.model_dump())
db.add(new_book)
await db.commit()
await db.refresh(new_book)
return await _get_book_response(db, new_book)
@router.get("", response_model=List[BookResponse])
async def get_books(
skip: int = 0,
limit: int = 50,
search: Optional[str] = Query(None, description="Search by book title or author"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""모든 서적 목록 조회"""
query = select(Book)
if search:
query = query.where(
or_(
Book.title.ilike(f"%{search}%"),
Book.author.ilike(f"%{search}%")
)
)
# Only show public books or books owned by the current user/admin
if not current_user.is_admin:
query = query.where(Book.is_public == True) # For simplicity, assuming all books are public for now or user can only see public ones.
# In a real app, you'd link books to users.
query = query.offset(skip).limit(limit).order_by(Book.title)
result = await db.execute(query)
books = result.scalars().all()
response_books = []
for book in books:
response_books.append(await _get_book_response(db, book))
return response_books
@router.get("/{book_id}", response_model=BookResponse)
async def get_book(
book_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 서적 상세 정보 조회"""
result = await db.execute(
select(Book).where(Book.id == book_id)
)
book = result.scalar_one_or_none()
if not book:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
# Access control (simplified)
if not book.is_public and not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions to access this book")
return await _get_book_response(db, book)
@router.put("/{book_id}", response_model=BookResponse)
async def update_book(
book_id: UUID,
book_data: UpdateBookRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""서적 정보 업데이트"""
if not current_user.is_admin: # Only admin can update books for now
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update books")
result = await db.execute(
select(Book).where(Book.id == book_id)
)
book = result.scalar_one_or_none()
if not book:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
for field, value in book_data.model_dump(exclude_unset=True).items():
setattr(book, field, value)
await db.commit()
await db.refresh(book)
return await _get_book_response(db, book)
@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_book(
book_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""서적 삭제"""
if not current_user.is_admin: # Only admin can delete books for now
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete books")
result = await db.execute(
select(Book).where(Book.id == book_id)
)
book = result.scalar_one_or_none()
if not book:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found")
# Disassociate documents from this book before deleting
await db.execute(
select(Document).where(Document.book_id == book_id)
)
documents_to_update = (await db.execute(select(Document).where(Document.book_id == book_id))).scalars().all()
for doc in documents_to_update:
doc.book_id = None
await db.delete(book)
await db.commit()
return {"message": "Book deleted successfully"}
@router.get("/search/", response_model=List[BookSearchResponse])
async def search_books(
q: str = Query(..., min_length=1, description="Search query for book title or author"),
limit: int = Query(10, ge=1, le=100),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""서적 검색 (제목 또는 저자)"""
query = select(Book).where(
or_(
Book.title.ilike(f"%{q}%"),
Book.author.ilike(f"%{q}%")
)
)
if not current_user.is_admin:
query = query.where(Book.is_public == True)
result = await db.execute(query.limit(limit))
books = result.scalars().all()
response_books = []
for book in books:
response_books.append(await _get_book_response(db, book))
return response_books
@router.get("/suggestions/", response_model=List[BookSuggestionResponse])
async def get_book_suggestions(
title: str = Query(..., min_length=1, description="Book title for suggestions"),
limit: int = Query(5, ge=1, le=10),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""제목 유사도 기반 서적 추천"""
all_books_query = select(Book)
if not current_user.is_admin:
all_books_query = all_books_query.where(Book.is_public == True)
all_books_result = await db.execute(all_books_query)
all_books = all_books_result.scalars().all()
suggestions = []
for book in all_books:
# Calculate similarity score using difflib
score = difflib.SequenceMatcher(None, title.lower(), book.title.lower()).ratio()
if score > 0.1: # Only consider if there's some similarity
suggestions.append({
"book": book,
"similarity_score": score
})
# Sort by similarity score in descending order
suggestions.sort(key=lambda x: x["similarity_score"], reverse=True)
response_suggestions = []
for s in suggestions[:limit]:
book_response = await _get_book_response(db, s["book"])
response_suggestions.append(BookSuggestionResponse(
**book_response.model_dump(),
similarity_score=s["similarity_score"]
))
return response_suggestions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,27 @@
from sqlalchemy import Column, String, DateTime, Text, Boolean
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from ..core.database import Base
class Book(Base):
"""서적 테이블 (여러 문서를 묶는 단위)"""
__tablename__ = "books"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(500), nullable=False, index=True)
author = Column(String(255), nullable=True)
description = Column(Text, nullable=True)
language = Column(String(10), default="ko")
is_public = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계
documents = relationship("Document", back_populates="book", cascade="all, delete-orphan")
categories = relationship("BookCategory", back_populates="book", cascade="all, delete-orphan", order_by="BookCategory.sort_order")
def __repr__(self):
return f"<Book(title='{self.title}', author='{self.author}')>"

View File

@@ -0,0 +1,26 @@
from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from ..core.database import Base
class BookCategory(Base):
"""서적 소분류 테이블 (서적 내 문서 그룹화)"""
__tablename__ = "book_categories"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
book_id = Column(UUID(as_uuid=True), ForeignKey('books.id', ondelete='CASCADE'), nullable=False, index=True)
name = Column(String(200), nullable=False) # 소분류 이름 (예: "Chapter 1", "설계 기준", "계산서")
description = Column(Text, nullable=True) # 소분류 설명
sort_order = Column(Integer, default=0) # 소분류 정렬 순서
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 관계
book = relationship("Book", back_populates="categories")
documents = relationship("Document", back_populates="category", cascade="all, delete-orphan")
def __repr__(self):
return f"<BookCategory(name='{self.name}', book='{self.book.title if self.book else None}')>"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from uuid import UUID
class BookBase(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
author: Optional[str] = Field(None, max_length=255)
description: Optional[str] = None
language: str = Field("ko", max_length=10)
is_public: bool = False
class CreateBookRequest(BookBase):
pass
class UpdateBookRequest(BookBase):
pass
class BookResponse(BookBase):
id: UUID
created_at: datetime
updated_at: Optional[datetime]
document_count: int = 0 # 문서 개수 추가
class Config:
from_attributes = True
class BookSearchResponse(BookResponse):
pass
class BookSuggestionResponse(BookResponse):
similarity_score: float = Field(..., ge=0.0, le=1.0)

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from uuid import UUID
class BookCategoryBase(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
sort_order: int = Field(0, ge=0)
class CreateBookCategoryRequest(BookCategoryBase):
book_id: UUID
class UpdateBookCategoryRequest(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
sort_order: Optional[int] = Field(None, ge=0)
class BookCategoryResponse(BookCategoryBase):
id: UUID
book_id: UUID
created_at: datetime
updated_at: Optional[datetime]
document_count: int = 0 # 포함된 문서 수
class Config:
from_attributes = True
class UpdateDocumentOrderRequest(BaseModel):
document_orders: List[dict] = Field(..., description="문서 ID와 순서 정보")
# 예: [{"document_id": "uuid", "sort_order": 1}, ...]

View File

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

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

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