- 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>
222 lines
5.7 KiB
Python
222 lines
5.7 KiB
Python
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import Optional
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
|
|
class Priority(str, Enum):
|
|
HIGH = "high"
|
|
MEDIUM = "medium"
|
|
LOW = "low"
|
|
|
|
|
|
# === Request 스키마 ===
|
|
|
|
|
|
class TodoCreate(BaseModel):
|
|
"""할일 생성 요청 (F-001)"""
|
|
title: str = Field(..., min_length=1, max_length=200)
|
|
content: Optional[str] = Field(None, max_length=2000)
|
|
category_id: Optional[str] = None
|
|
tags: list[str] = Field(default_factory=list, max_length=10)
|
|
priority: Priority = Priority.MEDIUM
|
|
start_date: Optional[datetime] = None
|
|
due_date: Optional[datetime] = None
|
|
|
|
@field_validator("title")
|
|
@classmethod
|
|
def title_not_blank(cls, v: str) -> str:
|
|
v = v.strip()
|
|
if not v:
|
|
raise ValueError("제목은 공백만으로 구성할 수 없습니다")
|
|
return v
|
|
|
|
@field_validator("tags")
|
|
@classmethod
|
|
def normalize_tags(cls, v: list[str]) -> list[str]:
|
|
"""소문자 정규화, 중복 제거, 공백 trim"""
|
|
seen: set[str] = set()
|
|
result: list[str] = []
|
|
for tag in v:
|
|
tag = tag.strip().lower()
|
|
if tag and len(tag) <= 30 and tag not in seen:
|
|
seen.add(tag)
|
|
result.append(tag)
|
|
return result
|
|
|
|
@field_validator("category_id")
|
|
@classmethod
|
|
def validate_category_id(cls, v: Optional[str]) -> Optional[str]:
|
|
from bson import ObjectId
|
|
if v is not None and not ObjectId.is_valid(v):
|
|
raise ValueError("유효하지 않은 카테고리 ID 형식입니다")
|
|
return v
|
|
|
|
|
|
class TodoUpdate(BaseModel):
|
|
"""할일 수정 요청 (F-004) - Partial Update"""
|
|
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
|
content: Optional[str] = Field(None, max_length=2000)
|
|
completed: Optional[bool] = None
|
|
category_id: Optional[str] = None
|
|
tags: Optional[list[str]] = None
|
|
priority: Optional[Priority] = None
|
|
start_date: Optional[datetime] = None
|
|
due_date: Optional[datetime] = None
|
|
|
|
@field_validator("title")
|
|
@classmethod
|
|
def title_not_blank(cls, v: Optional[str]) -> Optional[str]:
|
|
if v is not None:
|
|
v = v.strip()
|
|
if not v:
|
|
raise ValueError("제목은 공백만으로 구성할 수 없습니다")
|
|
return v
|
|
|
|
@field_validator("tags")
|
|
@classmethod
|
|
def normalize_tags(cls, v: Optional[list[str]]) -> Optional[list[str]]:
|
|
if v is None:
|
|
return None
|
|
seen: set[str] = set()
|
|
result: list[str] = []
|
|
for tag in v:
|
|
tag = tag.strip().lower()
|
|
if tag and len(tag) <= 30 and tag not in seen:
|
|
seen.add(tag)
|
|
result.append(tag)
|
|
return result
|
|
|
|
@field_validator("category_id")
|
|
@classmethod
|
|
def validate_category_id(cls, v: Optional[str]) -> Optional[str]:
|
|
from bson import ObjectId
|
|
if v is not None and not ObjectId.is_valid(v):
|
|
raise ValueError("유효하지 않은 카테고리 ID 형식입니다")
|
|
return v
|
|
|
|
|
|
class BatchRequest(BaseModel):
|
|
"""일괄 작업 요청 (F-019, F-020, F-021)"""
|
|
action: str = Field(..., pattern="^(complete|delete|move_category)$")
|
|
ids: list[str] = Field(..., min_length=1)
|
|
category_id: Optional[str] = None # move_category 시에만 사용
|
|
|
|
@field_validator("ids")
|
|
@classmethod
|
|
def validate_ids(cls, v: list[str]) -> list[str]:
|
|
from bson import ObjectId
|
|
for id_str in v:
|
|
if not ObjectId.is_valid(id_str):
|
|
raise ValueError(f"유효하지 않은 ID 형식: {id_str}")
|
|
return v
|
|
|
|
|
|
# === Attachment 스키마 ===
|
|
|
|
|
|
class Attachment(BaseModel):
|
|
"""첨부파일 메타데이터"""
|
|
id: str
|
|
filename: str
|
|
stored_filename: str
|
|
content_type: str
|
|
size: int
|
|
uploaded_at: datetime
|
|
|
|
|
|
# === Response 스키마 ===
|
|
|
|
|
|
class TodoResponse(BaseModel):
|
|
"""할일 응답 (카테고리 정보 포함 가능)"""
|
|
id: str
|
|
title: str
|
|
content: Optional[str] = None
|
|
completed: bool
|
|
priority: Priority
|
|
category_id: Optional[str] = None
|
|
category_name: Optional[str] = None
|
|
category_color: Optional[str] = None
|
|
tags: list[str] = []
|
|
start_date: Optional[datetime] = None
|
|
due_date: Optional[datetime] = None
|
|
attachments: list[Attachment] = []
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class TodoListResponse(BaseModel):
|
|
"""할일 목록 페이지네이션 응답 (F-002)"""
|
|
items: list[TodoResponse]
|
|
total: int
|
|
page: int
|
|
limit: int
|
|
total_pages: int
|
|
|
|
|
|
class ToggleResponse(BaseModel):
|
|
"""완료 토글 응답 (F-006)"""
|
|
id: str
|
|
completed: bool
|
|
|
|
|
|
class BatchResponse(BaseModel):
|
|
"""일괄 작업 응답 (F-019, F-020, F-021)"""
|
|
action: str
|
|
processed: int
|
|
failed: int
|
|
|
|
|
|
class TagInfo(BaseModel):
|
|
"""태그 정보 (F-013)"""
|
|
name: str
|
|
count: int
|
|
|
|
|
|
class SearchResponse(BaseModel):
|
|
"""검색 응답 (F-017)"""
|
|
items: list[TodoResponse]
|
|
total: int
|
|
query: str
|
|
page: int
|
|
limit: int
|
|
|
|
|
|
# === 대시보드 통계 모델 ===
|
|
|
|
|
|
class OverviewStats(BaseModel):
|
|
total: int
|
|
completed: int
|
|
incomplete: int
|
|
completion_rate: float
|
|
|
|
|
|
class CategoryStats(BaseModel):
|
|
category_id: Optional[str] = None
|
|
name: str
|
|
color: str
|
|
count: int
|
|
|
|
|
|
class PriorityStats(BaseModel):
|
|
high: int
|
|
medium: int
|
|
low: int
|
|
|
|
|
|
class UpcomingDeadline(BaseModel):
|
|
id: str
|
|
title: str
|
|
due_date: datetime
|
|
priority: Priority
|
|
|
|
|
|
class DashboardStats(BaseModel):
|
|
"""대시보드 통계 응답 (F-018)"""
|
|
overview: OverviewStats
|
|
by_category: list[CategoryStats]
|
|
by_priority: PriorityStats
|
|
upcoming_deadlines: list[UpcomingDeadline]
|