import math 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.todo import ( Priority, TodoCreate, TodoUpdate, TodoResponse, TodoListResponse, BatchRequest, BatchResponse, ToggleResponse, TagInfo, ) # 우선순위 정렬용 매핑 (높음이 먼저 오도록) PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2} DASHBOARD_CACHE_KEY = "dashboard:stats" class TodoService: """할일 CRUD + 일괄 작업 + 태그 집계 비즈니스 로직""" def __init__(self, db: AsyncIOMotorDatabase, redis_client: Optional[aioredis.Redis] = None): self.collection = db["todos"] self.categories = db["categories"] 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 # Redis 오류 시 무시 (캐시 무효화 실패는 치명적이지 않음) async def _populate_category(self, todo_doc: dict) -> TodoResponse: """Todo 문서에 카테고리 정보를 추가하여 TodoResponse를 반환한다.""" category_name = None category_color = None cat_id = todo_doc.get("category_id") if cat_id: category = await self.categories.find_one({"_id": ObjectId(cat_id)}) if category: category_name = category["name"] category_color = category["color"] return TodoResponse( id=str(todo_doc["_id"]), title=todo_doc["title"], content=todo_doc.get("content"), completed=todo_doc["completed"], priority=todo_doc["priority"], category_id=str(todo_doc["category_id"]) if todo_doc.get("category_id") else None, category_name=category_name, category_color=category_color, tags=todo_doc.get("tags", []), start_date=todo_doc.get("start_date"), due_date=todo_doc.get("due_date"), attachments=todo_doc.get("attachments", []), created_at=todo_doc["created_at"], updated_at=todo_doc["updated_at"], ) async def _populate_categories_bulk(self, todo_docs: list[dict]) -> list[TodoResponse]: """여러 Todo 문서의 카테고리 정보를 일괄 조회하여 TodoResponse 리스트를 반환한다.""" # 카테고리 ID 수집 cat_ids = set() for doc in todo_docs: cat_id = doc.get("category_id") if cat_id: try: cat_ids.add(ObjectId(cat_id)) except Exception: pass # 카테고리 일괄 조회 cat_map: dict[str, dict] = {} if cat_ids: async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}): cat_map[str(cat["_id"])] = cat # TodoResponse 변환 result = [] for doc in todo_docs: cat_id = str(doc["category_id"]) if doc.get("category_id") else None cat = cat_map.get(cat_id) if cat_id else None result.append(TodoResponse( id=str(doc["_id"]), title=doc["title"], content=doc.get("content"), completed=doc["completed"], priority=doc["priority"], category_id=cat_id, category_name=cat["name"] if cat else None, category_color=cat["color"] if cat else None, tags=doc.get("tags", []), start_date=doc.get("start_date"), due_date=doc.get("due_date"), attachments=doc.get("attachments", []), created_at=doc["created_at"], updated_at=doc["updated_at"], )) return result # --- CRUD --- async def create_todo(self, data: TodoCreate) -> TodoResponse: """F-001: 할일 생성""" # category_id 존재 검증 if data.category_id: cat = await self.categories.find_one({"_id": ObjectId(data.category_id)}) if not cat: raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") now = datetime.now(timezone.utc) doc = { "title": data.title, "content": data.content, "completed": False, "priority": data.priority.value, "category_id": data.category_id, "tags": data.tags, "start_date": data.start_date, "due_date": data.due_date, "created_at": now, "updated_at": now, } result = await self.collection.insert_one(doc) doc["_id"] = result.inserted_id await self._invalidate_cache() return await self._populate_category(doc) async def list_todos( self, page: int = 1, limit: int = 20, completed: Optional[bool] = None, category_id: Optional[str] = None, priority: Optional[str] = None, tag: Optional[str] = None, sort: str = "created_at", order: str = "desc", ) -> TodoListResponse: """F-002: 할일 목록 조회 (필터, 정렬, 페이지네이션)""" # 필터 조건 구성 (AND) query: dict = {} if completed is not None: query["completed"] = completed if category_id: query["category_id"] = category_id if priority: query["priority"] = priority if tag: query["tags"] = tag # 정렬 조건 구성 sort_direction = 1 if order == "asc" else -1 if sort == "priority": # priority 정렬 시 매핑 사용 (MongoDB에선 문자열 정렬이므로 보조 필드 필요) # 대안: aggregation pipeline 사용 pipeline = [] if query: pipeline.append({"$match": query}) pipeline.append({ "$addFields": { "priority_order": { "$switch": { "branches": [ {"case": {"$eq": ["$priority", "high"]}, "then": 0}, {"case": {"$eq": ["$priority", "medium"]}, "then": 1}, {"case": {"$eq": ["$priority", "low"]}, "then": 2}, ], "default": 1, } } } }) pipeline.append({"$sort": {"priority_order": sort_direction, "created_at": -1}}) # 총 개수 조회 count_pipeline = list(pipeline) + [{"$count": "total"}] count_result = await self.collection.aggregate(count_pipeline).to_list(1) total = count_result[0]["total"] if count_result else 0 # 페이지네이션 skip = (page - 1) * limit pipeline.append({"$skip": skip}) pipeline.append({"$limit": limit}) pipeline.append({"$project": {"priority_order": 0}}) docs = await self.collection.aggregate(pipeline).to_list(limit) else: # 일반 정렬 sort_field = sort if sort in ("created_at", "due_date") else "created_at" sort_spec = [(sort_field, sort_direction)] total = await self.collection.count_documents(query) skip = (page - 1) * limit cursor = self.collection.find(query).sort(sort_spec).skip(skip).limit(limit) docs = await cursor.to_list(limit) total_pages = math.ceil(total / limit) if limit > 0 else 0 items = await self._populate_categories_bulk(docs) return TodoListResponse( items=items, total=total, page=page, limit=limit, total_pages=total_pages, ) async def get_todo(self, todo_id: str) -> TodoResponse: """F-003: 할일 상세 조회""" if not ObjectId.is_valid(todo_id): raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") doc = await self.collection.find_one({"_id": ObjectId(todo_id)}) if not doc: raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") return await self._populate_category(doc) async def update_todo(self, todo_id: str, data: TodoUpdate) -> TodoResponse: """F-004: 할일 수정 (Partial Update)""" if not ObjectId.is_valid(todo_id): raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") # 변경 필드만 추출 update_data = data.model_dump(exclude_unset=True) if not update_data: raise HTTPException(status_code=400, detail="수정할 필드가 없습니다") # category_id 존재 검증 if "category_id" in update_data and update_data["category_id"] is not None: cat = await self.categories.find_one({"_id": ObjectId(update_data["category_id"])}) if not cat: raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") # priority enum -> string 변환 if "priority" in update_data and update_data["priority"] is not None: update_data["priority"] = update_data["priority"].value if hasattr(update_data["priority"], "value") else update_data["priority"] update_data["updated_at"] = datetime.now(timezone.utc) result = await self.collection.find_one_and_update( {"_id": ObjectId(todo_id)}, {"$set": update_data}, return_document=True, ) if not result: raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") await self._invalidate_cache() return await self._populate_category(result) async def delete_todo(self, todo_id: str) -> None: """F-005: 할일 삭제""" if not ObjectId.is_valid(todo_id): raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") result = await self.collection.delete_one({"_id": ObjectId(todo_id)}) if result.deleted_count == 0: raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") # 첨부파일 삭제 from app.services.file_service import FileService file_service = FileService(self.collection.database) await file_service.delete_all_todo_files(todo_id) await self._invalidate_cache() async def toggle_todo(self, todo_id: str) -> ToggleResponse: """F-006: 완료 토글""" if not ObjectId.is_valid(todo_id): raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") doc = await self.collection.find_one({"_id": ObjectId(todo_id)}) if not doc: raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") new_completed = not doc["completed"] await self.collection.update_one( {"_id": ObjectId(todo_id)}, {"$set": { "completed": new_completed, "updated_at": datetime.now(timezone.utc), }}, ) await self._invalidate_cache() return ToggleResponse(id=todo_id, completed=new_completed) # --- 일괄 작업 --- async def batch_action(self, data: BatchRequest) -> BatchResponse: """F-019~F-021: 일괄 작업 분기""" if data.action == "complete": return await self._batch_complete(data.ids) elif data.action == "delete": return await self._batch_delete(data.ids) elif data.action == "move_category": return await self._batch_move_category(data.ids, data.category_id) else: raise HTTPException(status_code=400, detail="지원하지 않는 작업입니다") async def _batch_complete(self, ids: list[str]) -> BatchResponse: """F-019: 일괄 완료""" object_ids = [ObjectId(id_str) for id_str in ids] result = await self.collection.update_many( {"_id": {"$in": object_ids}}, {"$set": { "completed": True, "updated_at": datetime.now(timezone.utc), }}, ) await self._invalidate_cache() return BatchResponse( action="complete", processed=result.modified_count, failed=len(ids) - result.modified_count, ) async def _batch_delete(self, ids: list[str]) -> BatchResponse: """F-020: 일괄 삭제""" object_ids = [ObjectId(id_str) for id_str in ids] result = await self.collection.delete_many({"_id": {"$in": object_ids}}) await self._invalidate_cache() return BatchResponse( action="delete", processed=result.deleted_count, failed=len(ids) - result.deleted_count, ) async def _batch_move_category(self, ids: list[str], category_id: Optional[str]) -> BatchResponse: """F-021: 일괄 카테고리 변경""" # category_id 검증 (null이 아닌 경우) if category_id is not None: if not ObjectId.is_valid(category_id): raise HTTPException(status_code=422, detail="유효하지 않은 카테고리 ID 형식입니다") cat = await self.categories.find_one({"_id": ObjectId(category_id)}) if not cat: raise HTTPException(status_code=404, detail="카테고리를 찾을 수 없습니다") object_ids = [ObjectId(id_str) for id_str in ids] result = await self.collection.update_many( {"_id": {"$in": object_ids}}, {"$set": { "category_id": category_id, "updated_at": datetime.now(timezone.utc), }}, ) await self._invalidate_cache() return BatchResponse( action="move_category", processed=result.modified_count, failed=len(ids) - result.modified_count, ) # --- 태그 --- async def get_tags(self) -> list[TagInfo]: """F-013: 태그 목록 조회 (사용 횟수 포함, 사용 횟수 내림차순)""" pipeline = [ {"$unwind": "$tags"}, {"$group": {"_id": "$tags", "count": {"$sum": 1}}}, {"$sort": {"count": -1}}, {"$project": {"name": "$_id", "count": 1, "_id": 0}}, ] results = await self.collection.aggregate(pipeline).to_list(None) return [TagInfo(name=r["name"], count=r["count"]) for r in results]