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