"""자료실 분류 체계 CRUD API — /api/library""" from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy import text as sql_text from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from core.database import get_session from core.library import LIBRARY_PREFIX, MAX_DEPTH, normalize_library_path from models.category import LibraryCategory from models.document import Document from models.facet_value import FacetValue from models.user import User FACET_TYPES = ("company", "topic", "doctype") # year는 사전 불필요 router = APIRouter() # ─── 스키마 ─── class CategoryCreate(BaseModel): path: str class CategoryRename(BaseModel): path: str new_name: str class CategoryResponse(BaseModel): id: int path: str name: str parent_path: str | None depth: int is_system: bool created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class CategoryTreeNode(BaseModel): name: str path: str count: int # 현재 사용자 기준, 해당 경로 (하위 경로 포함) 의 안 본 자료 수. # 0 이면 모두 1+회독. unread_count: int = 0 is_category: bool is_system: bool has_children: bool children: list["CategoryTreeNode"] # ─── 엔드포인트 ─── @router.get("/categories", response_model=list[CategoryResponse]) async def list_categories( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """전체 카테고리 flat 목록 (path 순)""" result = await session.execute( select(LibraryCategory).order_by(LibraryCategory.path) ) return [CategoryResponse.model_validate(c) for c in result.scalars().all()] @router.post("/categories", response_model=CategoryResponse, status_code=201) async def create_category( body: CategoryCreate, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """카테고리 생성 (조상 자동 생성 포함)""" try: normalized = normalize_library_path(body.path) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) segments = normalized.split("/") if len(segments) > MAX_DEPTH: raise HTTPException(status_code=400, detail=f"최대 {MAX_DEPTH}단계까지 가능") # 중복 검사 existing = await session.execute( select(LibraryCategory).where(LibraryCategory.path == normalized) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="이미 존재하는 분류 경로") # 조상 자동 생성 for i in range(1, len(segments)): ancestor_path = "/".join(segments[:i]) ancestor_name = segments[i - 1] ancestor_parent = "/".join(segments[: i - 1]) or None exists = await session.execute( select(LibraryCategory.id).where( LibraryCategory.path == ancestor_path ) ) if not exists.scalar_one_or_none(): session.add(LibraryCategory( path=ancestor_path, name=ancestor_name, parent_path=ancestor_parent, depth=i, )) # 본 카테고리 생성 category = LibraryCategory( path=normalized, name=segments[-1], parent_path="/".join(segments[:-1]) or None, depth=len(segments), ) session.add(category) await session.commit() await session.refresh(category) return CategoryResponse.model_validate(category) @router.patch("/categories", response_model=CategoryResponse) async def rename_category( body: CategoryRename, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """카테고리 이름 변경 (leaf only, path 기반 식별)""" # 카테고리 조회 result = await session.execute( select(LibraryCategory).where(LibraryCategory.path == body.path) ) category = result.scalar_one_or_none() if not category: raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다") # 시스템 분류 보호 if category.is_system: raise HTTPException(status_code=422, detail="시스템 분류는 변경할 수 없습니다") # leaf 검사 children = await session.execute( select(func.count()).where( LibraryCategory.parent_path == category.path ) ) if children.scalar() > 0: raise HTTPException( status_code=422, detail="하위 분류가 있어 이름을 변경할 수 없습니다" ) # new_name 검증 new_name = body.new_name.strip() if not new_name: raise HTTPException(status_code=400, detail="빈 이름") if len(new_name) > 30: raise HTTPException(status_code=400, detail="이름은 30자 이하") # 새 path 계산 new_path = ( f"{category.parent_path}/{new_name}" if category.parent_path else new_name ) # 중복 검사 dup = await session.execute( select(LibraryCategory.id).where(LibraryCategory.path == new_path) ) if dup.scalar_one_or_none(): raise HTTPException(status_code=409, detail="같은 이름의 분류가 이미 존재합니다") old_tag = f"{LIBRARY_PREFIX}{category.path}" new_tag = f"{LIBRARY_PREFIX}{new_path}" # 문서 태그 갱신 await session.execute( sql_text(""" UPDATE documents SET user_tags = COALESCE(( SELECT jsonb_agg( CASE WHEN elem = :old_tag THEN :new_tag ELSE elem END ) FROM jsonb_array_elements_text( COALESCE(user_tags, '[]'::jsonb) ) AS elem ), '[]'::jsonb) WHERE user_tags @> :old_tag_jsonb """).bindparams( old_tag=old_tag, new_tag=new_tag, old_tag_jsonb=f'["{old_tag}"]', ) ) # 카테고리 row 갱신 (path, name만. parent_path 유지) category.path = new_path category.name = new_name await session.commit() await session.refresh(category) return CategoryResponse.model_validate(category) @router.delete("/categories", status_code=204) async def delete_category( path: str = Query(..., description="삭제할 카테고리 경로"), user: Annotated[User, Depends(get_current_user)] = None, session: Annotated[AsyncSession, Depends(get_session)] = None, ): """카테고리 삭제 (leaf only, 문서 없는 경우만)""" result = await session.execute( select(LibraryCategory).where(LibraryCategory.path == path) ) category = result.scalar_one_or_none() if not category: raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다") if category.is_system: raise HTTPException(status_code=422, detail="시스템 분류는 삭제할 수 없습니다") # leaf 검사 children = await session.execute( select(func.count()).where( LibraryCategory.parent_path == category.path ) ) if children.scalar() > 0: raise HTTPException( status_code=422, detail="하위 분류가 있어 삭제할 수 없습니다" ) # 문서 연결 검사 tag = f"{LIBRARY_PREFIX}{category.path}" doc_count = await session.execute( sql_text(""" SELECT COUNT(*) FROM documents WHERE deleted_at IS NULL AND EXISTS ( SELECT 1 FROM jsonb_array_elements_text( COALESCE(user_tags, '[]'::jsonb) ) AS t WHERE t = :tag ) """).bindparams(tag=tag) ) if doc_count.scalar() > 0: raise HTTPException( status_code=422, detail="이 분류에 속한 문서가 있어 삭제할 수 없습니다. 문서를 먼저 이동하세요.", ) await session.delete(category) await session.commit() @router.get("/tree", response_model=list[CategoryTreeNode]) async def get_library_tree( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """카테고리 저장소 + 문서 태그 count 머지 트리""" # 1. 카테고리 전체 fetch cat_result = await session.execute( select(LibraryCategory).order_by(LibraryCategory.path) ) categories = cat_result.scalars().all() # path → category 매핑 cat_map: dict[str, LibraryCategory] = {c.path: c for c in categories} # 2. 문서 태그에서 doc count 집계 doc_result = await session.execute( select(Document.id, Document.user_tags).where( Document.deleted_at == None, # noqa: E711 Document.user_tags != None, # noqa: E711 ) ) # path → set of doc_ids path_docs: dict[str, set[int]] = {} for doc_id, tags in doc_result: if not tags: continue seen_ancestors: set[str] = set() for tag in tags: if not isinstance(tag, str) or not tag.startswith(LIBRARY_PREFIX): continue path = tag[len(LIBRARY_PREFIX):] parts = path.split("/") for i in range(1, len(parts) + 1): ancestor = "/".join(parts[:i]) if ancestor not in seen_ancestors: path_docs.setdefault(ancestor, set()).add(doc_id) seen_ancestors.add(ancestor) # 2.5 현재 사용자가 1+회독 한 doc_id 집합 (안 본 자료 = 전체 - 읽음) from models.document_read import DocumentRead read_result = await session.execute( select(DocumentRead.document_id) .where(DocumentRead.user_id == user.id) .group_by(DocumentRead.document_id) ) read_doc_ids: set[int] = {r[0] for r in read_result} # 3. 모든 path 합산 (카테고리 + 태그) all_paths = set(cat_map.keys()) | set(path_docs.keys()) # 4. 트리 구축 root: dict = {} for p in sorted(all_paths): parts = p.split("/") node = root for i, part in enumerate(parts): if part not in node: node[part] = {"_children": {}} node = node[part]["_children"] if i < len(parts) - 1 else node[part] def build_tree(d: dict, prefix: str = "") -> list[dict]: nodes = [] for name, data in sorted(d.items()): if name.startswith("_"): continue path = f"{prefix}/{name}" if prefix else name children_dict = data.get("_children", {}) children = build_tree(children_dict, path) cat = cat_map.get(path) # path_docs[path] 는 이미 본 노드의 자손 doc 까지 누적되어 있음 (위 ancestor 누적 로직). # 따라서 unread_count 도 하위 경로 전체 합산 (bottom-up 별도 계산 불필요). docs_at_path = path_docs.get(path, set()) unread = len(docs_at_path - read_doc_ids) nodes.append(CategoryTreeNode( name=name, path=path, count=len(docs_at_path), unread_count=unread, is_category=path in cat_map, is_system=cat.is_system if cat else False, has_children=len(children) > 0, children=children, )) return nodes return build_tree(root) # ─── Facet API (Phase 2) ─── class FacetValueResponse(BaseModel): facet_type: str value: str model_config = {"from_attributes": True} class FacetCountItem(BaseModel): value: str count: int class FacetCountsResponse(BaseModel): company: list[FacetCountItem] topic: list[FacetCountItem] year: list[FacetCountItem] doctype: list[FacetCountItem] @router.get("/facets", response_model=dict[str, list[str]]) async def get_facet_values( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """facet 축별 허용값 사전 (year는 실제 데이터 기반)""" result: dict[str, list[str]] = {} for ft in FACET_TYPES: rows = await session.execute( select(FacetValue.value) .where(FacetValue.facet_type == ft) .order_by(FacetValue.value) ) result[ft] = [r[0] for r in rows] # year는 사전 없이 실제 문서 값에서 추출 year_rows = await session.execute( select(Document.facet_year) .where( Document.deleted_at == None, # noqa: E711 Document.facet_year != None, # noqa: E711 ) .distinct() .order_by(Document.facet_year.desc()) ) result["year"] = [str(r[0]) for r in year_rows] return result @router.post("/facets", response_model=FacetValueResponse, status_code=201) async def add_facet_value( body: FacetValueResponse, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """facet 사전에 새 값 추가""" if body.facet_type not in FACET_TYPES: raise HTTPException(status_code=400, detail=f"허용 facet: {', '.join(FACET_TYPES)}") value = body.value.strip() if not value: raise HTTPException(status_code=400, detail="빈 값") existing = await session.execute( select(FacetValue).where( FacetValue.facet_type == body.facet_type, FacetValue.value == value, ) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="이미 존재하는 값") fv = FacetValue(facet_type=body.facet_type, value=value) session.add(fv) await session.commit() return FacetValueResponse(facet_type=body.facet_type, value=value) @router.get("/facet-counts", response_model=FacetCountsResponse) async def get_facet_counts( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], library_path: str | None = None, facet_company: str | None = None, facet_topic: str | None = None, facet_year: int | None = None, facet_doctype: str | None = None, q: str | None = None, ): """현재 필터 기준 facet별 집계 count""" def base_query(): query = select(Document).where( Document.deleted_at == None, # noqa: E711 Document.doc_purpose == "business", ) if library_path: exact = f"{LIBRARY_PREFIX}{library_path}" prefix = f"{LIBRARY_PREFIX}{library_path}/%" query = query.where( sql_text(""" EXISTS ( SELECT 1 FROM jsonb_array_elements_text( COALESCE(documents.user_tags, '[]'::jsonb) ) AS t WHERE t = :exact OR t LIKE :prefix ) """).bindparams(exact=exact, prefix=prefix) ) if q: query = query.where(Document.title.ilike(f"%{q}%")) return query result = FacetCountsResponse(company=[], topic=[], year=[], doctype=[]) # company counts (다른 facet 필터 적용, 자기 자신 제외) q_company = base_query() if facet_topic: q_company = q_company.where(Document.facet_topic == facet_topic) if facet_year: q_company = q_company.where(Document.facet_year == facet_year) if facet_doctype: q_company = q_company.where(Document.facet_doctype == facet_doctype) rows = await session.execute( select(Document.facet_company, func.count()) .where(Document.facet_company != None) # noqa: E711 .where(Document.id.in_(q_company.with_only_columns(Document.id).subquery().select())) .group_by(Document.facet_company) .order_by(func.count().desc()) ) result.company = [FacetCountItem(value=r[0], count=r[1]) for r in rows] # topic counts q_topic = base_query() if facet_company: q_topic = q_topic.where(Document.facet_company == facet_company) if facet_year: q_topic = q_topic.where(Document.facet_year == facet_year) if facet_doctype: q_topic = q_topic.where(Document.facet_doctype == facet_doctype) rows = await session.execute( select(Document.facet_topic, func.count()) .where(Document.facet_topic != None) # noqa: E711 .where(Document.id.in_(q_topic.with_only_columns(Document.id).subquery().select())) .group_by(Document.facet_topic) .order_by(func.count().desc()) ) result.topic = [FacetCountItem(value=r[0], count=r[1]) for r in rows] # year counts q_year = base_query() if facet_company: q_year = q_year.where(Document.facet_company == facet_company) if facet_topic: q_year = q_year.where(Document.facet_topic == facet_topic) if facet_doctype: q_year = q_year.where(Document.facet_doctype == facet_doctype) rows = await session.execute( select(Document.facet_year, func.count()) .where(Document.facet_year != None) # noqa: E711 .where(Document.id.in_(q_year.with_only_columns(Document.id).subquery().select())) .group_by(Document.facet_year) .order_by(Document.facet_year.desc()) ) result.year = [FacetCountItem(value=str(r[0]), count=r[1]) for r in rows] # doctype counts q_doctype = base_query() if facet_company: q_doctype = q_doctype.where(Document.facet_company == facet_company) if facet_topic: q_doctype = q_doctype.where(Document.facet_topic == facet_topic) if facet_year: q_doctype = q_doctype.where(Document.facet_year == facet_year) rows = await session.execute( select(Document.facet_doctype, func.count()) .where(Document.facet_doctype != None) # noqa: E711 .where(Document.id.in_(q_doctype.with_only_columns(Document.id).subquery().select())) .group_by(Document.facet_doctype) .order_by(func.count().desc()) ) result.doctype = [FacetCountItem(value=r[0], count=r[1]) for r in rows] return result