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:
396
backend/app/services/todo_service.py
Normal file
396
backend/app/services/todo_service.py
Normal file
@ -0,0 +1,396 @@
|
||||
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]
|
||||
Reference in New Issue
Block a user