feat: 풀스택 할일관리 앱 구현 (통합 모달 + 간트차트)
- Backend: FastAPI + MongoDB + Redis (카테고리, 할일 CRUD, 파일 첨부, 검색, 대시보드) - Frontend: Next.js 15 + Tailwind + React Query + Zustand - 통합 TodoModal: 생성/수정 모달 통합, 탭 구조 (기본/태그와 첨부) - 간트차트: 카테고리별 할일 타임라인 시각화 - TodoCard: 제목/카테고리/우선순위/태그/첨부 한줄 표시 - Docker Compose 배포 (Frontend:3010, Backend:8010, MongoDB:27021, Redis:6391) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
161
backend/app/services/category_service.py
Normal file
161
backend/app/services/category_service.py
Normal file
@ -0,0 +1,161 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user