from datetime import datetime, timezone from typing import Optional from bson import ObjectId from fastapi import HTTPException from motor.motor_asyncio import AsyncIOMotorDatabase import redis.asyncio as aioredis from app.models.category import ( CategoryCreate, CategoryUpdate, CategoryResponse, ) DASHBOARD_CACHE_KEY = "dashboard:stats" class CategoryService: """카테고리 CRUD 비즈니스 로직""" def __init__(self, db: AsyncIOMotorDatabase, redis_client: Optional[aioredis.Redis] = None): self.collection = db["categories"] self.todos = db["todos"] self.redis = redis_client async def _invalidate_cache(self): """대시보드 캐시 무효화""" if self.redis is not None: try: await self.redis.delete(DASHBOARD_CACHE_KEY) except Exception: pass async def list_categories(self) -> list[CategoryResponse]: """F-008: 카테고리 목록 조회 (order 오름차순, todo_count 포함)""" categories = await self.collection.find().sort("order", 1).to_list(None) result = [] for cat in categories: cat_id_str = str(cat["_id"]) # 해당 카테고리의 할일 수 조회 todo_count = await self.todos.count_documents({"category_id": cat_id_str}) result.append(CategoryResponse( id=cat_id_str, name=cat["name"], color=cat.get("color", "#6B7280"), order=cat.get("order", 0), todo_count=todo_count, created_at=cat["created_at"], )) return result async def create_category(self, data: CategoryCreate) -> CategoryResponse: """F-007: 카테고리 생성""" # 이름 중복 검사 existing = await self.collection.find_one({"name": data.name}) if existing: raise HTTPException(status_code=409, detail="이미 존재하는 카테고리 이름입니다") # order 자동 설정 (현재 최대값 + 1) max_order_doc = await self.collection.find_one( {}, sort=[("order", -1)], ) next_order = (max_order_doc["order"] + 1) if max_order_doc and "order" in max_order_doc else 0 now = datetime.now(timezone.utc) doc = { "name": data.name, "color": data.color, "order": next_order, "created_at": now, } result = await self.collection.insert_one(doc) doc["_id"] = result.inserted_id await self._invalidate_cache() return CategoryResponse( id=str(doc["_id"]), name=doc["name"], color=doc["color"], order=doc["order"], todo_count=0, created_at=doc["created_at"], ) async def update_category(self, category_id: str, data: CategoryUpdate) -> CategoryResponse: """F-009: 카테고리 수정 (Partial Update)""" if not ObjectId.is_valid(category_id): raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") # 존재 확인 existing = await self.collection.find_one({"_id": ObjectId(category_id)}) if not existing: raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") # 변경 필드 추출 update_data = data.model_dump(exclude_unset=True) if not update_data: raise HTTPException(status_code=400, detail="수정할 필드가 없습니다") # name 변경 시 중복 검사 if "name" in update_data: dup = await self.collection.find_one({ "name": update_data["name"], "_id": {"$ne": ObjectId(category_id)}, }) if dup: raise HTTPException(status_code=409, detail="이미 존재하는 카테고리 이름입니다") result = await self.collection.find_one_and_update( {"_id": ObjectId(category_id)}, {"$set": update_data}, return_document=True, ) if not result: raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") # todo_count 조회 cat_id_str = str(result["_id"]) todo_count = await self.todos.count_documents({"category_id": cat_id_str}) await self._invalidate_cache() return CategoryResponse( id=cat_id_str, name=result["name"], color=result.get("color", "#6B7280"), order=result.get("order", 0), todo_count=todo_count, created_at=result["created_at"], ) async def delete_category(self, category_id: str) -> None: """F-010: 카테고리 삭제 + 해당 카테고리의 할일 category_id null 처리""" if not ObjectId.is_valid(category_id): raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") # 존재 확인 existing = await self.collection.find_one({"_id": ObjectId(category_id)}) if not existing: raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") # 해당 카테고리의 모든 todo.category_id -> null 갱신 await self.todos.update_many( {"category_id": category_id}, {"$set": { "category_id": None, "updated_at": datetime.now(timezone.utc), }}, ) # 카테고리 삭제 await self.collection.delete_one({"_id": ObjectId(category_id)}) await self._invalidate_cache()