""" 트리 구조 메모장 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)}" )