feat: 소설 분기 시스템 및 트리 메모장 구현
🌟 주요 기능: - 트리 구조 메모장 시스템 - 소설 분기 관리 (정사 경로 설정) - 중앙 배치 트리 다이어그램 - 정사 경로 목차 뷰 - 인라인 편집 기능 📚 백엔드: - MemoTree, MemoNode 모델 추가 - 정사 경로 자동 순서 관리 - 분기점에서 하나만 선택 가능한 로직 - RESTful API 엔드포인트 🎨 프론트엔드: - memo-tree.html: 트리 다이어그램 에디터 - story-view.html: 정사 경로 목차 뷰 - SVG 연결선으로 시각적 트리 표현 - Alpine.js 기반 반응형 UI - Monaco Editor 통합 ✨ 특별 기능: - 정사 경로 황금색 배지 표시 - 확대/축소 및 패닝 지원 - 드래그 앤 드롭 준비 - 내보내기 및 인쇄 기능 - 인라인 편집 모달
This commit is contained in:
153
backend/database/migrations/005_create_memo_tree_tables.sql
Normal file
153
backend/database/migrations/005_create_memo_tree_tables.sql
Normal 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;
|
||||
98
backend/database/migrations/006_add_canonical_path.sql
Normal file
98
backend/database/migrations/006_add_canonical_path.sql
Normal 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 '정사 경로 문자열 표현';
|
||||
97
backend/database/migrations/007_fix_canonical_order.sql
Normal file
97
backend/database/migrations/007_fix_canonical_order.sql
Normal 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 $$;
|
||||
700
backend/src/api/routes/memo_trees.py
Normal file
700
backend/src/api/routes/memo_trees.py
Normal file
@@ -0,0 +1,700 @@
|
||||
"""
|
||||
트리 구조 메모장 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...models.user import User
|
||||
from ...models.memo_tree import MemoTree, MemoNode, MemoNodeVersion, MemoTreeShare
|
||||
from ...schemas.memo_tree import (
|
||||
MemoTreeCreate, MemoTreeUpdate, MemoTreeResponse, MemoTreeWithNodes,
|
||||
MemoNodeCreate, MemoNodeUpdate, MemoNodeResponse, MemoNodeMove,
|
||||
MemoTreeStats, MemoSearchRequest, MemoSearchResult
|
||||
)
|
||||
from ..dependencies import get_current_active_user
|
||||
|
||||
router = APIRouter(prefix="/memo-trees", tags=["memo-trees"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 메모 트리 관리
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/", response_model=List[MemoTreeResponse])
|
||||
async def get_user_memo_trees(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
include_archived: bool = Query(False, description="보관된 트리 포함 여부")
|
||||
):
|
||||
"""사용자의 메모 트리 목록 조회"""
|
||||
try:
|
||||
query = select(MemoTree).where(MemoTree.user_id == current_user.id)
|
||||
|
||||
if not include_archived:
|
||||
query = query.where(MemoTree.is_archived == False)
|
||||
|
||||
query = query.order_by(MemoTree.updated_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
trees = result.scalars().all()
|
||||
|
||||
# 각 트리의 노드 개수 계산
|
||||
tree_responses = []
|
||||
for tree in trees:
|
||||
node_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
|
||||
)
|
||||
node_count = node_count_result.scalar() or 0
|
||||
|
||||
tree_dict = {
|
||||
"id": str(tree.id),
|
||||
"user_id": str(tree.user_id),
|
||||
"title": tree.title,
|
||||
"description": tree.description,
|
||||
"tree_type": tree.tree_type,
|
||||
"template_data": tree.template_data,
|
||||
"settings": tree.settings,
|
||||
"created_at": tree.created_at,
|
||||
"updated_at": tree.updated_at,
|
||||
"is_public": tree.is_public,
|
||||
"is_archived": tree.is_archived,
|
||||
"node_count": node_count
|
||||
}
|
||||
tree_responses.append(MemoTreeResponse(**tree_dict))
|
||||
|
||||
return tree_responses
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_user_memo_trees: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo trees: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=MemoTreeResponse)
|
||||
async def create_memo_tree(
|
||||
tree_data: MemoTreeCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새 메모 트리 생성"""
|
||||
try:
|
||||
new_tree = MemoTree(
|
||||
user_id=current_user.id,
|
||||
title=tree_data.title,
|
||||
description=tree_data.description,
|
||||
tree_type=tree_data.tree_type,
|
||||
template_data=tree_data.template_data or {},
|
||||
settings=tree_data.settings or {},
|
||||
is_public=tree_data.is_public
|
||||
)
|
||||
|
||||
db.add(new_tree)
|
||||
await db.commit()
|
||||
await db.refresh(new_tree)
|
||||
|
||||
tree_dict = {
|
||||
"id": str(new_tree.id),
|
||||
"user_id": str(new_tree.user_id),
|
||||
"title": new_tree.title,
|
||||
"description": new_tree.description,
|
||||
"tree_type": new_tree.tree_type,
|
||||
"template_data": new_tree.template_data,
|
||||
"settings": new_tree.settings,
|
||||
"created_at": new_tree.created_at,
|
||||
"updated_at": new_tree.updated_at,
|
||||
"is_public": new_tree.is_public,
|
||||
"is_archived": new_tree.is_archived,
|
||||
"node_count": 0
|
||||
}
|
||||
|
||||
return MemoTreeResponse(**tree_dict)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in create_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tree_id}", response_model=MemoTreeResponse)
|
||||
async def get_memo_tree(
|
||||
tree_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리 상세 조회"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
or_(
|
||||
MemoTree.user_id == current_user.id,
|
||||
MemoTree.is_public == True
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 노드 개수 계산
|
||||
node_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
|
||||
)
|
||||
node_count = node_count_result.scalar() or 0
|
||||
|
||||
tree_dict = {
|
||||
"id": str(tree.id),
|
||||
"user_id": str(tree.user_id),
|
||||
"title": tree.title,
|
||||
"description": tree.description,
|
||||
"tree_type": tree.tree_type,
|
||||
"template_data": tree.template_data,
|
||||
"settings": tree.settings,
|
||||
"created_at": tree.created_at,
|
||||
"updated_at": tree.updated_at,
|
||||
"is_public": tree.is_public,
|
||||
"is_archived": tree.is_archived,
|
||||
"node_count": node_count
|
||||
}
|
||||
|
||||
return MemoTreeResponse(**tree_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{tree_id}", response_model=MemoTreeResponse)
|
||||
async def update_memo_tree(
|
||||
tree_id: UUID,
|
||||
tree_data: MemoTreeUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리 업데이트"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
MemoTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 업데이트할 필드들 적용
|
||||
update_data = tree_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tree, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tree)
|
||||
|
||||
# 노드 개수 계산
|
||||
node_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
|
||||
)
|
||||
node_count = node_count_result.scalar() or 0
|
||||
|
||||
tree_dict = {
|
||||
"id": str(tree.id),
|
||||
"user_id": str(tree.user_id),
|
||||
"title": tree.title,
|
||||
"description": tree.description,
|
||||
"tree_type": tree.tree_type,
|
||||
"template_data": tree.template_data,
|
||||
"settings": tree.settings,
|
||||
"created_at": tree.created_at,
|
||||
"updated_at": tree.updated_at,
|
||||
"is_public": tree.is_public,
|
||||
"is_archived": tree.is_archived,
|
||||
"node_count": node_count
|
||||
}
|
||||
|
||||
return MemoTreeResponse(**tree_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in update_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tree_id}")
|
||||
async def delete_memo_tree(
|
||||
tree_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리 삭제"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
MemoTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 트리 삭제 (CASCADE로 관련 노드들도 자동 삭제됨)
|
||||
await db.delete(tree)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Memo tree deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in delete_memo_tree: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete memo tree: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 메모 노드 관리
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/{tree_id}/nodes", response_model=List[MemoNodeResponse])
|
||||
async def get_memo_tree_nodes(
|
||||
tree_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 트리의 모든 노드 조회"""
|
||||
try:
|
||||
# 트리 접근 권한 확인
|
||||
tree_result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
or_(
|
||||
MemoTree.user_id == current_user.id,
|
||||
MemoTree.is_public == True
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = tree_result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 노드들 조회
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.where(MemoNode.tree_id == tree_id)
|
||||
.order_by(MemoNode.path, MemoNode.sort_order)
|
||||
)
|
||||
nodes = result.scalars().all()
|
||||
|
||||
# 각 노드의 자식 개수 계산
|
||||
node_responses = []
|
||||
for node in nodes:
|
||||
children_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
|
||||
)
|
||||
children_count = children_count_result.scalar() or 0
|
||||
|
||||
node_dict = {
|
||||
"id": str(node.id),
|
||||
"tree_id": str(node.tree_id),
|
||||
"parent_id": str(node.parent_id) if node.parent_id else None,
|
||||
"user_id": str(node.user_id),
|
||||
"title": node.title,
|
||||
"content": node.content,
|
||||
"node_type": node.node_type,
|
||||
"sort_order": node.sort_order,
|
||||
"depth_level": node.depth_level,
|
||||
"path": node.path,
|
||||
"tags": node.tags or [],
|
||||
"node_metadata": node.node_metadata or {},
|
||||
"status": node.status,
|
||||
"word_count": node.word_count,
|
||||
"is_canonical": node.is_canonical,
|
||||
"canonical_order": node.canonical_order,
|
||||
"story_path": node.story_path,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"children_count": children_count
|
||||
}
|
||||
node_responses.append(MemoNodeResponse(**node_dict))
|
||||
|
||||
return node_responses
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_memo_tree_nodes: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo tree nodes: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tree_id}/nodes", response_model=MemoNodeResponse)
|
||||
async def create_memo_node(
|
||||
tree_id: UUID,
|
||||
node_data: MemoNodeCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새 메모 노드 생성"""
|
||||
try:
|
||||
# 트리 접근 권한 확인
|
||||
tree_result = await db.execute(
|
||||
select(MemoTree).where(
|
||||
and_(
|
||||
MemoTree.id == tree_id,
|
||||
MemoTree.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
tree = tree_result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo tree not found"
|
||||
)
|
||||
|
||||
# 부모 노드 확인 (있다면)
|
||||
if node_data.parent_id:
|
||||
parent_result = await db.execute(
|
||||
select(MemoNode).where(
|
||||
and_(
|
||||
MemoNode.id == UUID(node_data.parent_id),
|
||||
MemoNode.tree_id == tree_id
|
||||
)
|
||||
)
|
||||
)
|
||||
parent_node = parent_result.scalar_one_or_none()
|
||||
if not parent_node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent node not found"
|
||||
)
|
||||
|
||||
# 단어 수 계산
|
||||
word_count = 0
|
||||
if node_data.content:
|
||||
word_count = len(node_data.content.replace('\n', ' ').split())
|
||||
|
||||
new_node = MemoNode(
|
||||
tree_id=tree_id,
|
||||
parent_id=UUID(node_data.parent_id) if node_data.parent_id else None,
|
||||
user_id=current_user.id,
|
||||
title=node_data.title,
|
||||
content=node_data.content,
|
||||
node_type=node_data.node_type,
|
||||
sort_order=node_data.sort_order,
|
||||
tags=node_data.tags or [],
|
||||
node_metadata=node_data.node_metadata or {},
|
||||
status=node_data.status,
|
||||
word_count=word_count,
|
||||
is_canonical=node_data.is_canonical or False
|
||||
)
|
||||
|
||||
db.add(new_node)
|
||||
await db.commit()
|
||||
await db.refresh(new_node)
|
||||
|
||||
node_dict = {
|
||||
"id": str(new_node.id),
|
||||
"tree_id": str(new_node.tree_id),
|
||||
"parent_id": str(new_node.parent_id) if new_node.parent_id else None,
|
||||
"user_id": str(new_node.user_id),
|
||||
"title": new_node.title,
|
||||
"content": new_node.content,
|
||||
"node_type": new_node.node_type,
|
||||
"sort_order": new_node.sort_order,
|
||||
"depth_level": new_node.depth_level,
|
||||
"path": new_node.path,
|
||||
"tags": new_node.tags or [],
|
||||
"node_metadata": new_node.node_metadata or {},
|
||||
"status": new_node.status,
|
||||
"word_count": new_node.word_count,
|
||||
"is_canonical": new_node.is_canonical,
|
||||
"canonical_order": new_node.canonical_order,
|
||||
"story_path": new_node.story_path,
|
||||
"created_at": new_node.created_at,
|
||||
"updated_at": new_node.updated_at,
|
||||
"children_count": 0
|
||||
}
|
||||
|
||||
return MemoNodeResponse(**node_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in create_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create memo node: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nodes/{node_id}", response_model=MemoNodeResponse)
|
||||
async def get_memo_node(
|
||||
node_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 노드 상세 조회"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.options(selectinload(MemoNode.tree))
|
||||
.where(MemoNode.id == node_id)
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo node not found"
|
||||
)
|
||||
|
||||
# 접근 권한 확인
|
||||
if node.tree.user_id != current_user.id and not node.tree.is_public:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to access this node"
|
||||
)
|
||||
|
||||
# 자식 개수 계산
|
||||
children_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
|
||||
)
|
||||
children_count = children_count_result.scalar() or 0
|
||||
|
||||
node_dict = {
|
||||
"id": str(node.id),
|
||||
"tree_id": str(node.tree_id),
|
||||
"parent_id": str(node.parent_id) if node.parent_id else None,
|
||||
"user_id": str(node.user_id),
|
||||
"title": node.title,
|
||||
"content": node.content,
|
||||
"node_type": node.node_type,
|
||||
"sort_order": node.sort_order,
|
||||
"depth_level": node.depth_level,
|
||||
"path": node.path,
|
||||
"tags": node.tags or [],
|
||||
"node_metadata": node.node_metadata or {},
|
||||
"status": node.status,
|
||||
"word_count": node.word_count,
|
||||
"is_canonical": node.is_canonical,
|
||||
"canonical_order": node.canonical_order,
|
||||
"story_path": node.story_path,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"children_count": children_count
|
||||
}
|
||||
|
||||
return MemoNodeResponse(**node_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR in get_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get memo node: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/nodes/{node_id}", response_model=MemoNodeResponse)
|
||||
async def update_memo_node(
|
||||
node_id: UUID,
|
||||
node_data: MemoNodeUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 노드 업데이트"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.options(selectinload(MemoNode.tree))
|
||||
.where(MemoNode.id == node_id)
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo node not found"
|
||||
)
|
||||
|
||||
# 접근 권한 확인 (소유자만 수정 가능)
|
||||
if node.tree.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to update this node"
|
||||
)
|
||||
|
||||
# 업데이트할 필드들 적용
|
||||
update_data = node_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == "parent_id" and value:
|
||||
# 부모 노드 유효성 검사
|
||||
parent_result = await db.execute(
|
||||
select(MemoNode).where(
|
||||
and_(
|
||||
MemoNode.id == UUID(value),
|
||||
MemoNode.tree_id == node.tree_id
|
||||
)
|
||||
)
|
||||
)
|
||||
parent_node = parent_result.scalar_one_or_none()
|
||||
if not parent_node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent node not found"
|
||||
)
|
||||
setattr(node, field, UUID(value))
|
||||
elif field == "parent_id" and value is None:
|
||||
setattr(node, field, None)
|
||||
else:
|
||||
setattr(node, field, value)
|
||||
|
||||
# 내용이 업데이트되면 단어 수 재계산
|
||||
if "content" in update_data:
|
||||
word_count = 0
|
||||
if node.content:
|
||||
word_count = len(node.content.replace('\n', ' ').split())
|
||||
node.word_count = word_count
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(node)
|
||||
|
||||
# 자식 개수 계산
|
||||
children_count_result = await db.execute(
|
||||
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
|
||||
)
|
||||
children_count = children_count_result.scalar() or 0
|
||||
|
||||
node_dict = {
|
||||
"id": str(node.id),
|
||||
"tree_id": str(node.tree_id),
|
||||
"parent_id": str(node.parent_id) if node.parent_id else None,
|
||||
"user_id": str(node.user_id),
|
||||
"title": node.title,
|
||||
"content": node.content,
|
||||
"node_type": node.node_type,
|
||||
"sort_order": node.sort_order,
|
||||
"depth_level": node.depth_level,
|
||||
"path": node.path,
|
||||
"tags": node.tags or [],
|
||||
"node_metadata": node.node_metadata or {},
|
||||
"status": node.status,
|
||||
"word_count": node.word_count,
|
||||
"is_canonical": node.is_canonical,
|
||||
"canonical_order": node.canonical_order,
|
||||
"story_path": node.story_path,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"children_count": children_count
|
||||
}
|
||||
|
||||
return MemoNodeResponse(**node_dict)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in update_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update memo node: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/nodes/{node_id}")
|
||||
async def delete_memo_node(
|
||||
node_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 노드 삭제"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(MemoNode)
|
||||
.options(selectinload(MemoNode.tree))
|
||||
.where(MemoNode.id == node_id)
|
||||
)
|
||||
node = result.scalar_one_or_none()
|
||||
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memo node not found"
|
||||
)
|
||||
|
||||
# 접근 권한 확인 (소유자만 삭제 가능)
|
||||
if node.tree.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to delete this node"
|
||||
)
|
||||
|
||||
# 노드 삭제 (CASCADE로 자식 노드들도 자동 삭제됨)
|
||||
await db.delete(node)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Memo node deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"ERROR in delete_memo_node: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete memo node: {str(e)}"
|
||||
)
|
||||
@@ -9,7 +9,7 @@ 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
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -51,6 +51,7 @@ 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.get("/")
|
||||
|
||||
111
backend/src/models/memo_tree.py
Normal file
111
backend/src/models/memo_tree.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
트리 구조 메모장 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class MemoTree(Base):
|
||||
"""메모 트리 (프로젝트/워크스페이스)"""
|
||||
__tablename__ = "memo_trees"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text)
|
||||
tree_type = Column(String(50), default="general") # 'novel', 'research', 'project', 'general'
|
||||
template_data = Column(JSON) # 템플릿별 메타데이터
|
||||
settings = Column(JSON, default={}) # 트리별 설정
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
is_public = Column(Boolean, default=False)
|
||||
is_archived = Column(Boolean, default=False)
|
||||
|
||||
# 관계
|
||||
user = relationship("User", back_populates="memo_trees")
|
||||
nodes = relationship("MemoNode", back_populates="tree", cascade="all, delete-orphan")
|
||||
shares = relationship("MemoTreeShare", back_populates="tree", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MemoNode(Base):
|
||||
"""메모 노드 (트리의 각 노드)"""
|
||||
__tablename__ = "memo_nodes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"))
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# 기본 정보
|
||||
title = Column(String(500), nullable=False)
|
||||
content = Column(Text) # Markdown 형식
|
||||
node_type = Column(String(50), default="memo") # 'folder', 'memo', 'chapter', 'character', 'plot'
|
||||
|
||||
# 트리 구조 관리
|
||||
sort_order = Column(Integer, default=0)
|
||||
depth_level = Column(Integer, default=0)
|
||||
path = Column(Text) # 경로 저장 (예: /1/3/7)
|
||||
|
||||
# 메타데이터
|
||||
tags = Column(ARRAY(String)) # 태그 배열
|
||||
node_metadata = Column(JSON, default={}) # 노드별 메타데이터
|
||||
|
||||
# 상태 관리
|
||||
status = Column(String(50), default="draft") # 'draft', 'writing', 'review', 'complete'
|
||||
word_count = Column(Integer, default=0)
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical = Column(Boolean, default=False) # 정사 경로 여부
|
||||
canonical_order = Column(Integer, nullable=True) # 정사 경로 순서
|
||||
story_path = Column(Text, nullable=True) # 정사 경로 문자열
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# 관계
|
||||
tree = relationship("MemoTree", back_populates="nodes")
|
||||
user = relationship("User", back_populates="memo_nodes")
|
||||
parent = relationship("MemoNode", remote_side=[id], back_populates="children")
|
||||
children = relationship("MemoNode", back_populates="parent", cascade="all, delete-orphan")
|
||||
versions = relationship("MemoNodeVersion", back_populates="node", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MemoNodeVersion(Base):
|
||||
"""메모 노드 버전 관리"""
|
||||
__tablename__ = "memo_node_versions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
node_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"), nullable=False)
|
||||
version_number = Column(Integer, nullable=False)
|
||||
title = Column(String(500), nullable=False)
|
||||
content = Column(Text)
|
||||
node_metadata = Column(JSON, default={})
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# 관계
|
||||
node = relationship("MemoNode", back_populates="versions")
|
||||
creator = relationship("User")
|
||||
|
||||
|
||||
class MemoTreeShare(Base):
|
||||
"""메모 트리 공유 (협업 기능)"""
|
||||
__tablename__ = "memo_tree_shares"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False)
|
||||
shared_with_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
permission_level = Column(String(20), default="read") # 'read', 'write', 'admin'
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# 관계
|
||||
tree = relationship("MemoTree", back_populates="shares")
|
||||
shared_with_user = relationship("User", foreign_keys=[shared_with_user_id])
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
@@ -30,5 +31,9 @@ class User(Base):
|
||||
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")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(email='{self.email}', full_name='{self.full_name}')>"
|
||||
|
||||
205
backend/src/schemas/memo_tree.py
Normal file
205
backend/src/schemas/memo_tree.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
트리 구조 메모장 Pydantic 스키마
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# 기본 스키마들
|
||||
class MemoTreeBase(BaseModel):
|
||||
"""메모 트리 기본 스키마"""
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
tree_type: str = Field(default="general", pattern="^(general|novel|research|project)$")
|
||||
template_data: Optional[Dict[str, Any]] = None
|
||||
settings: Optional[Dict[str, Any]] = None
|
||||
is_public: bool = False
|
||||
|
||||
|
||||
class MemoTreeCreate(MemoTreeBase):
|
||||
"""메모 트리 생성 요청"""
|
||||
pass
|
||||
|
||||
|
||||
class MemoTreeUpdate(BaseModel):
|
||||
"""메모 트리 업데이트 요청"""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
tree_type: Optional[str] = Field(None, pattern="^(general|novel|research|project)$")
|
||||
template_data: Optional[Dict[str, Any]] = None
|
||||
settings: Optional[Dict[str, Any]] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
|
||||
|
||||
class MemoTreeResponse(MemoTreeBase):
|
||||
"""메모 트리 응답"""
|
||||
id: str
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
is_archived: bool
|
||||
node_count: Optional[int] = 0 # 노드 개수
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 메모 노드 스키마들
|
||||
class MemoNodeBase(BaseModel):
|
||||
"""메모 노드 기본 스키마"""
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
content: Optional[str] = None
|
||||
node_type: str = Field(default="memo", pattern="^(folder|memo|chapter|character|plot)$")
|
||||
tags: Optional[List[str]] = None
|
||||
node_metadata: Optional[Dict[str, Any]] = None
|
||||
status: str = Field(default="draft", pattern="^(draft|writing|review|complete)$")
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical: Optional[bool] = False
|
||||
canonical_order: Optional[int] = None
|
||||
|
||||
|
||||
class MemoNodeCreate(MemoNodeBase):
|
||||
"""메모 노드 생성 요청"""
|
||||
tree_id: str
|
||||
parent_id: Optional[str] = None
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
|
||||
class MemoNodeUpdate(BaseModel):
|
||||
"""메모 노드 업데이트 요청"""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
content: Optional[str] = None
|
||||
node_type: Optional[str] = Field(None, pattern="^(folder|memo|chapter|character|plot)$")
|
||||
parent_id: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
tags: Optional[List[str]] = None
|
||||
node_metadata: Optional[Dict[str, Any]] = None
|
||||
status: Optional[str] = Field(None, pattern="^(draft|writing|review|complete)$")
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical: Optional[bool] = None
|
||||
canonical_order: Optional[int] = None
|
||||
|
||||
|
||||
class MemoNodeMove(BaseModel):
|
||||
"""메모 노드 이동 요청"""
|
||||
parent_id: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class MemoNodeResponse(MemoNodeBase):
|
||||
"""메모 노드 응답"""
|
||||
id: str
|
||||
tree_id: str
|
||||
parent_id: Optional[str]
|
||||
user_id: str
|
||||
sort_order: int
|
||||
depth_level: int
|
||||
path: Optional[str]
|
||||
word_count: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
# 정사 경로 관련 필드
|
||||
is_canonical: bool
|
||||
canonical_order: Optional[int]
|
||||
story_path: Optional[str]
|
||||
|
||||
# 관계 데이터
|
||||
children_count: Optional[int] = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 트리 구조 응답
|
||||
class MemoTreeWithNodes(MemoTreeResponse):
|
||||
"""노드가 포함된 메모 트리 응답"""
|
||||
nodes: List[MemoNodeResponse] = []
|
||||
|
||||
|
||||
# 노드 버전 스키마들
|
||||
class MemoNodeVersionResponse(BaseModel):
|
||||
"""메모 노드 버전 응답"""
|
||||
id: str
|
||||
node_id: str
|
||||
version_number: int
|
||||
title: str
|
||||
content: Optional[str]
|
||||
node_metadata: Optional[Dict[str, Any]]
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 공유 스키마들
|
||||
class MemoTreeShareCreate(BaseModel):
|
||||
"""메모 트리 공유 생성 요청"""
|
||||
shared_with_user_email: str
|
||||
permission_level: str = Field(default="read", pattern="^(read|write|admin)$")
|
||||
|
||||
|
||||
class MemoTreeShareResponse(BaseModel):
|
||||
"""메모 트리 공유 응답"""
|
||||
id: str
|
||||
tree_id: str
|
||||
shared_with_user_id: str
|
||||
shared_with_user_email: str
|
||||
shared_with_user_name: str
|
||||
permission_level: str
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 검색 및 필터링
|
||||
class MemoSearchRequest(BaseModel):
|
||||
"""메모 검색 요청"""
|
||||
query: str = Field(..., min_length=1)
|
||||
tree_id: Optional[str] = None
|
||||
node_types: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
status: Optional[List[str]] = None
|
||||
|
||||
|
||||
class MemoSearchResult(BaseModel):
|
||||
"""메모 검색 결과"""
|
||||
node: MemoNodeResponse
|
||||
tree: MemoTreeResponse
|
||||
matches: List[Dict[str, Any]] # 매치된 부분들
|
||||
relevance_score: float
|
||||
|
||||
|
||||
# 통계 스키마
|
||||
class MemoTreeStats(BaseModel):
|
||||
"""메모 트리 통계"""
|
||||
total_nodes: int
|
||||
nodes_by_type: Dict[str, int]
|
||||
nodes_by_status: Dict[str, int]
|
||||
total_words: int
|
||||
last_updated: Optional[datetime]
|
||||
|
||||
|
||||
# 내보내기 스키마
|
||||
class ExportRequest(BaseModel):
|
||||
"""내보내기 요청"""
|
||||
tree_id: str
|
||||
format: str = Field(..., pattern="^(markdown|html|pdf|docx)$")
|
||||
include_metadata: bool = True
|
||||
node_types: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ExportResponse(BaseModel):
|
||||
"""내보내기 응답"""
|
||||
file_url: str
|
||||
file_name: str
|
||||
file_size: int
|
||||
created_at: datetime
|
||||
Reference in New Issue
Block a user