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]