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:
jungwoo choi
2026-02-12 15:45:03 +09:00
parent b54811ad8d
commit 074b5133bf
81 changed files with 17027 additions and 19 deletions

View File

@ -0,0 +1,11 @@
from app.services.todo_service import TodoService
from app.services.category_service import CategoryService
from app.services.search_service import SearchService
from app.services.dashboard_service import DashboardService
__all__ = [
"TodoService",
"CategoryService",
"SearchService",
"DashboardService",
]

View 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()

View File

@ -0,0 +1,182 @@
import json
from datetime import datetime, timezone
from bson import ObjectId
from motor.motor_asyncio import AsyncIOMotorDatabase
import redis.asyncio as aioredis
from app.models.todo import (
DashboardStats,
OverviewStats,
CategoryStats,
PriorityStats,
UpcomingDeadline,
)
DASHBOARD_CACHE_KEY = "dashboard:stats"
DASHBOARD_CACHE_TTL = 60 # 60초
class DashboardService:
"""대시보드 통계 + Redis 캐싱"""
def __init__(self, db: AsyncIOMotorDatabase, redis_client: aioredis.Redis):
self.todos = db["todos"]
self.categories = db["categories"]
self.redis = redis_client
async def get_stats(self) -> DashboardStats:
"""F-018: 대시보드 통계
1. Redis 캐시 확인
2. 캐시 히트 -> JSON 파싱 후 반환
3. 캐시 미스 -> MongoDB 집계 -> Redis 저장 (TTL 60s) -> 반환
"""
# Redis 캐시 확인
try:
cached = await self.redis.get(DASHBOARD_CACHE_KEY)
if cached:
data = json.loads(cached)
return DashboardStats(**data)
except Exception:
pass # Redis 에러 시 DB에서 직접 조회
# 캐시 미스: MongoDB 집계
stats = await self._compute_stats()
# Redis에 캐싱
try:
stats_json = stats.model_dump_json()
await self.redis.setex(DASHBOARD_CACHE_KEY, DASHBOARD_CACHE_TTL, stats_json)
except Exception:
pass # Redis 저장 실패는 무시
return stats
async def _compute_stats(self) -> DashboardStats:
"""MongoDB 집계 파이프라인으로 통계 계산"""
# 1. Overview (전체/완료/미완료/완료율)
total = await self.todos.count_documents({})
completed = await self.todos.count_documents({"completed": True})
incomplete = total - completed
completion_rate = round((completed / total * 100), 1) if total > 0 else 0.0
overview = OverviewStats(
total=total,
completed=completed,
incomplete=incomplete,
completion_rate=completion_rate,
)
# 2. By Category (카테고리별 할일 분포)
by_category = await self._compute_by_category()
# 3. By Priority (우선순위별 현황)
by_priority = await self._compute_by_priority()
# 4. Upcoming Deadlines (마감 임박 할일 상위 5개)
upcoming_deadlines = await self._compute_upcoming_deadlines()
return DashboardStats(
overview=overview,
by_category=by_category,
by_priority=by_priority,
upcoming_deadlines=upcoming_deadlines,
)
async def _compute_by_category(self) -> list[CategoryStats]:
"""카테고리별 할일 분포 집계"""
pipeline = [
{"$group": {
"_id": "$category_id",
"count": {"$sum": 1},
}},
{"$sort": {"count": -1}},
]
results = await self.todos.aggregate(pipeline).to_list(None)
# 카테고리 정보 조회
cat_ids = set()
for r in results:
if r["_id"]:
try:
cat_ids.add(ObjectId(r["_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
category_stats = []
for r in results:
cat_id = r["_id"]
if cat_id and cat_id in cat_map:
cat = cat_map[cat_id]
category_stats.append(CategoryStats(
category_id=cat_id,
name=cat["name"],
color=cat.get("color", "#6B7280"),
count=r["count"],
))
else:
category_stats.append(CategoryStats(
category_id=None,
name="미분류",
color="#9CA3AF",
count=r["count"],
))
return category_stats
async def _compute_by_priority(self) -> PriorityStats:
"""우선순위별 현황 집계"""
pipeline = [
{"$group": {
"_id": "$priority",
"count": {"$sum": 1},
}},
]
results = await self.todos.aggregate(pipeline).to_list(None)
priority_map = {"high": 0, "medium": 0, "low": 0}
for r in results:
if r["_id"] in priority_map:
priority_map[r["_id"]] = r["count"]
return PriorityStats(
high=priority_map["high"],
medium=priority_map["medium"],
low=priority_map["low"],
)
async def _compute_upcoming_deadlines(self) -> list[UpcomingDeadline]:
"""마감 임박 할일 상위 5개 (미완료, 마감일 있는 것, 마감일 오름차순)"""
now = datetime.now(timezone.utc)
cursor = self.todos.find({
"completed": False,
"due_date": {"$ne": None, "$gte": now},
}).sort("due_date", 1).limit(5)
docs = await cursor.to_list(5)
deadlines = []
for doc in docs:
deadlines.append(UpcomingDeadline(
id=str(doc["_id"]),
title=doc["title"],
due_date=doc["due_date"],
priority=doc["priority"],
))
return deadlines
async def invalidate_cache(self) -> None:
"""캐시 무효화: 할일 CUD 작업 후 호출"""
try:
await self.redis.delete(DASHBOARD_CACHE_KEY)
except Exception:
pass

View File

@ -0,0 +1,154 @@
import shutil
import uuid
from datetime import datetime, timezone
from pathlib import Path
from bson import ObjectId
from fastapi import HTTPException, UploadFile
from motor.motor_asyncio import AsyncIOMotorDatabase
from app.config import get_settings
from app.models.todo import Attachment
class FileService:
"""파일 업로드/다운로드/삭제 서비스"""
def __init__(self, db: AsyncIOMotorDatabase):
self.collection = db["todos"]
self.settings = get_settings()
self.upload_dir = Path(self.settings.upload_dir)
def _ensure_todo_dir(self, todo_id: str) -> Path:
todo_dir = self.upload_dir / todo_id
todo_dir.mkdir(parents=True, exist_ok=True)
return todo_dir
async def upload_files(
self, todo_id: str, files: list[UploadFile]
) -> list[Attachment]:
if not ObjectId.is_valid(todo_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
todo = await self.collection.find_one({"_id": ObjectId(todo_id)})
if not todo:
raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
existing_count = len(todo.get("attachments", []))
if existing_count + len(files) > self.settings.max_files_per_todo:
raise HTTPException(
status_code=400,
detail=f"최대 {self.settings.max_files_per_todo}개의 파일만 첨부할 수 있습니다",
)
todo_dir = self._ensure_todo_dir(todo_id)
attachments: list[Attachment] = []
saved_paths: list[Path] = []
try:
for file in files:
content = await file.read()
if len(content) > self.settings.max_file_size:
raise HTTPException(
status_code=400,
detail=f"파일 '{file.filename}'이 최대 크기(10MB)를 초과합니다",
)
file_id = str(uuid.uuid4())
ext = Path(file.filename or "").suffix
stored_filename = f"{file_id}{ext}"
file_path = todo_dir / stored_filename
with open(file_path, "wb") as f:
f.write(content)
saved_paths.append(file_path)
attachments.append(
Attachment(
id=file_id,
filename=file.filename or "unknown",
stored_filename=stored_filename,
content_type=file.content_type or "application/octet-stream",
size=len(content),
uploaded_at=datetime.now(timezone.utc),
)
)
await self.collection.update_one(
{"_id": ObjectId(todo_id)},
{
"$push": {
"attachments": {
"$each": [a.model_dump() for a in attachments]
}
},
"$set": {"updated_at": datetime.now(timezone.utc)},
},
)
except HTTPException:
for p in saved_paths:
p.unlink(missing_ok=True)
raise
except Exception:
for p in saved_paths:
p.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="파일 업로드에 실패했습니다")
return attachments
async def delete_attachment(self, todo_id: str, attachment_id: str) -> None:
if not ObjectId.is_valid(todo_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
todo = await self.collection.find_one({"_id": ObjectId(todo_id)})
if not todo:
raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
attachment = None
for att in todo.get("attachments", []):
if att["id"] == attachment_id:
attachment = att
break
if not attachment:
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다")
file_path = self.upload_dir / todo_id / attachment["stored_filename"]
if file_path.exists():
file_path.unlink()
await self.collection.update_one(
{"_id": ObjectId(todo_id)},
{
"$pull": {"attachments": {"id": attachment_id}},
"$set": {"updated_at": datetime.now(timezone.utc)},
},
)
async def get_file_info(
self, todo_id: str, attachment_id: str
) -> tuple[Path, str]:
"""파일 경로와 원본 파일명을 반환"""
if not ObjectId.is_valid(todo_id):
raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다")
todo = await self.collection.find_one({"_id": ObjectId(todo_id)})
if not todo:
raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다")
for att in todo.get("attachments", []):
if att["id"] == attachment_id:
file_path = self.upload_dir / todo_id / att["stored_filename"]
if not file_path.exists():
raise HTTPException(
status_code=404, detail="파일이 디스크에서 찾을 수 없습니다"
)
return file_path, att["filename"]
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다")
async def delete_all_todo_files(self, todo_id: str) -> None:
"""Todo 삭제 시 해당 Todo의 모든 파일 삭제"""
todo_dir = self.upload_dir / todo_id
if todo_dir.exists():
shutil.rmtree(todo_dir)

View File

@ -0,0 +1,92 @@
import math
from bson import ObjectId
from fastapi import HTTPException
from motor.motor_asyncio import AsyncIOMotorDatabase
from app.models.todo import TodoResponse, SearchResponse
class SearchService:
"""MongoDB text index 기반 검색 로직"""
def __init__(self, db: AsyncIOMotorDatabase):
self.collection = db["todos"]
self.categories = db["categories"]
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", []),
due_date=doc.get("due_date"),
created_at=doc["created_at"],
updated_at=doc["updated_at"],
))
return result
async def search(self, query: str, page: int = 1, limit: int = 20) -> SearchResponse:
"""F-017: 전문 검색
MongoDB text index를 활용한 전문 검색, text score 기준 정렬, 페이지네이션
"""
if not query or not query.strip():
raise HTTPException(status_code=422, detail="검색어를 입력해주세요")
query = query.strip()
# MongoDB text search
search_filter = {"$text": {"$search": query}}
# 총 개수 조회
total = await self.collection.count_documents(search_filter)
# text score 기준 정렬 + 페이지네이션
skip = (page - 1) * limit
cursor = self.collection.find(
search_filter,
{"score": {"$meta": "textScore"}},
).sort(
[("score", {"$meta": "textScore"})]
).skip(skip).limit(limit)
docs = await cursor.to_list(limit)
items = await self._populate_categories_bulk(docs)
return SearchResponse(
items=items,
total=total,
query=query,
page=page,
limit=limit,
)

View 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]