할일관리 시스템 구현 완료

주요 기능:
- memos/트위터 스타일 할일 입력
- 5단계 워크플로우: draft → scheduled → active → completed/delayed
- 2시간 이상 작업 자동 분할 제안 (1분/30분/1시간 선택)
- 시작날짜 기반 자동 활성화
- 할일별 댓글/메모 기능
- 개인별 할일 관리

백엔드:
- TodoItem, TodoComment 모델 추가
- 완전한 REST API 구현
- 자동 상태 전환 로직
- 분할 기능 지원

프론트엔드:
- 직관적인 탭 기반 UI
- 실시간 상태 업데이트
- 모달 기반 상세 관리
- 반응형 디자인

데이터베이스:
- PostgreSQL 테이블 및 인덱스 생성
- 트리거 기반 자동 업데이트
This commit is contained in:
Hyungi Ahn
2025-09-04 10:40:49 +09:00
parent f221a5611c
commit a4fd233ba1
10 changed files with 1882 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
-- 할일관리 시스템 테이블 생성
-- 할일 아이템 테이블
CREATE TABLE IF NOT EXISTS todo_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 기본 정보
content TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'active', 'completed', 'delayed', 'split')),
-- 시간 관리
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
start_date TIMESTAMP WITH TIME ZONE,
estimated_minutes INTEGER CHECK (estimated_minutes > 0 AND estimated_minutes <= 120),
completed_at TIMESTAMP WITH TIME ZONE,
delayed_until TIMESTAMP WITH TIME ZONE,
-- 분할 관리
parent_id UUID REFERENCES todo_items(id) ON DELETE CASCADE,
split_order INTEGER,
-- 인덱스
CONSTRAINT unique_split_order UNIQUE (parent_id, split_order)
);
-- 할일 댓글 테이블
CREATE TABLE IF NOT EXISTS todo_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
todo_item_id UUID NOT NULL REFERENCES todo_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_todo_items_user_id ON todo_items(user_id);
CREATE INDEX IF NOT EXISTS idx_todo_items_status ON todo_items(status);
CREATE INDEX IF NOT EXISTS idx_todo_items_start_date ON todo_items(start_date);
CREATE INDEX IF NOT EXISTS idx_todo_items_parent_id ON todo_items(parent_id);
CREATE INDEX IF NOT EXISTS idx_todo_comments_todo_item_id ON todo_comments(todo_item_id);
CREATE INDEX IF NOT EXISTS idx_todo_comments_user_id ON todo_comments(user_id);
-- 트리거: updated_at 자동 업데이트
CREATE OR REPLACE FUNCTION update_todo_comments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_todo_comments_updated_at
BEFORE UPDATE ON todo_comments
FOR EACH ROW
EXECUTE FUNCTION update_todo_comments_updated_at();

View File

@@ -0,0 +1,663 @@
"""
할일관리 시스템 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import datetime, timedelta
from uuid import UUID
from ...core.database import get_db
from ...models.user import User
from ...models.todo import TodoItem, TodoComment
from ...schemas.todo import (
TodoItemCreate, TodoItemSchedule, TodoItemUpdate, TodoItemDelay, TodoItemSplit,
TodoItemResponse, TodoItemWithComments, TodoCommentCreate, TodoCommentUpdate,
TodoCommentResponse, TodoStats, TodoDashboard
)
from ..dependencies import get_current_active_user
router = APIRouter(prefix="/todos", tags=["todos"])
# ============================================================================
# 할일 아이템 관리
# ============================================================================
@router.post("/", response_model=TodoItemResponse)
async def create_todo_item(
todo_data: TodoItemCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새 할일 생성 (draft 상태)"""
try:
new_todo = TodoItem(
user_id=current_user.id,
content=todo_data.content,
status="draft"
)
db.add(new_todo)
await db.commit()
await db.refresh(new_todo)
# 응답 데이터 구성
response_data = TodoItemResponse(
id=new_todo.id,
user_id=new_todo.user_id,
content=new_todo.content,
status=new_todo.status,
created_at=new_todo.created_at,
start_date=new_todo.start_date,
estimated_minutes=new_todo.estimated_minutes,
completed_at=new_todo.completed_at,
delayed_until=new_todo.delayed_until,
parent_id=new_todo.parent_id,
split_order=new_todo.split_order,
comment_count=0
)
return response_data
except Exception as e:
await db.rollback()
print(f"ERROR in create_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create todo item: {str(e)}"
)
@router.post("/{todo_id}/schedule", response_model=TodoItemResponse)
async def schedule_todo_item(
todo_id: UUID,
schedule_data: TodoItemSchedule,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 일정 설정 (draft -> scheduled)"""
try:
# 할일 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "draft"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not in draft status"
)
# 2시간 이상인 경우 분할 제안
if schedule_data.estimated_minutes > 120:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tasks longer than 2 hours should be split into smaller tasks"
)
# 일정 설정
todo_item.start_date = schedule_data.start_date
todo_item.estimated_minutes = schedule_data.estimated_minutes
todo_item.status = "scheduled"
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in schedule_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to schedule todo item: {str(e)}"
)
@router.post("/{todo_id}/split", response_model=List[TodoItemResponse])
async def split_todo_item(
todo_id: UUID,
split_data: TodoItemSplit,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 분할"""
try:
# 원본 할일 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "draft"
)
)
)
original_todo = result.scalar_one_or_none()
if not original_todo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not in draft status"
)
# 분할된 할일들 생성
subtasks = []
for i, (subtask_content, estimated_minutes) in enumerate(zip(split_data.subtasks, split_data.estimated_minutes_per_task)):
if estimated_minutes > 120:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Subtask {i+1} is longer than 2 hours"
)
subtask = TodoItem(
user_id=current_user.id,
content=subtask_content,
status="draft",
parent_id=original_todo.id,
split_order=i + 1
)
db.add(subtask)
subtasks.append(subtask)
# 원본 할일 상태 변경 (분할됨 표시)
original_todo.status = "split"
await db.commit()
# 응답 데이터 구성
response_data = []
for subtask in subtasks:
await db.refresh(subtask)
response_data.append(TodoItemResponse(
id=subtask.id,
user_id=subtask.user_id,
content=subtask.content,
status=subtask.status,
created_at=subtask.created_at,
start_date=subtask.start_date,
estimated_minutes=subtask.estimated_minutes,
completed_at=subtask.completed_at,
delayed_until=subtask.delayed_until,
parent_id=subtask.parent_id,
split_order=subtask.split_order,
comment_count=0
))
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in split_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to split todo item: {str(e)}"
)
@router.get("/", response_model=List[TodoItemResponse])
async def get_todo_items(
status: Optional[str] = Query(None, regex="^(draft|scheduled|active|completed|delayed)$"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 목록 조회"""
try:
query = select(TodoItem).where(TodoItem.user_id == current_user.id)
if status:
query = query.where(TodoItem.status == status)
query = query.order_by(TodoItem.created_at.desc())
result = await db.execute(query)
todo_items = result.scalars().all()
# 각 할일의 댓글 수 계산
response_data = []
for todo_item in todo_items:
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data.append(TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
))
return response_data
except Exception as e:
print(f"ERROR in get_todo_items: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo items: {str(e)}"
)
@router.get("/active", response_model=List[TodoItemResponse])
async def get_active_todos(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""오늘 활성화된 할일들 조회"""
try:
now = datetime.utcnow()
# scheduled 상태이면서 시작일이 지난 것들을 active로 변경
update_result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.user_id == current_user.id,
TodoItem.status == "scheduled",
TodoItem.start_date <= now
)
)
)
scheduled_items = update_result.scalars().all()
for item in scheduled_items:
item.status = "active"
if scheduled_items:
await db.commit()
# active 상태인 할일들 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
).order_by(TodoItem.start_date.asc())
)
active_todos = result.scalars().all()
# 응답 데이터 구성
response_data = []
for todo_item in active_todos:
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data.append(TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
))
return response_data
except Exception as e:
print(f"ERROR in get_active_todos: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get active todos: {str(e)}"
)
@router.put("/{todo_id}/complete", response_model=TodoItemResponse)
async def complete_todo_item(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 완료"""
try:
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not active"
)
todo_item.status = "completed"
todo_item.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in complete_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to complete todo item: {str(e)}"
)
@router.put("/{todo_id}/delay", response_model=TodoItemResponse)
async def delay_todo_item(
todo_id: UUID,
delay_data: TodoItemDelay,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 지연"""
try:
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id,
TodoItem.status == "active"
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found or not active"
)
todo_item.status = "delayed"
todo_item.delayed_until = delay_data.delayed_until
todo_item.start_date = delay_data.delayed_until # 새로운 시작일로 업데이트
await db.commit()
await db.refresh(todo_item)
# 댓글 수 계산
comment_count_result = await db.execute(
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id)
)
comment_count = comment_count_result.scalar() or 0
response_data = TodoItemResponse(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=comment_count
)
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in delay_todo_item: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delay todo item: {str(e)}"
)
# ============================================================================
# 댓글 관리
# ============================================================================
@router.post("/{todo_id}/comments", response_model=TodoCommentResponse)
async def create_todo_comment(
todo_id: UUID,
comment_data: TodoCommentCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일에 댓글 추가"""
try:
# 할일 존재 확인
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
new_comment = TodoComment(
todo_item_id=todo_id,
user_id=current_user.id,
content=comment_data.content
)
db.add(new_comment)
await db.commit()
await db.refresh(new_comment)
return TodoCommentResponse(
id=new_comment.id,
todo_item_id=new_comment.todo_item_id,
user_id=new_comment.user_id,
content=new_comment.content,
created_at=new_comment.created_at,
updated_at=new_comment.updated_at
)
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in create_todo_comment: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create todo comment: {str(e)}"
)
@router.get("/{todo_id}/comments", response_model=List[TodoCommentResponse])
async def get_todo_comments(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 댓글 목록 조회"""
try:
# 할일 존재 확인
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
# 댓글 조회
result = await db.execute(
select(TodoComment).where(TodoComment.todo_item_id == todo_id)
.order_by(TodoComment.created_at.asc())
)
comments = result.scalars().all()
return [
TodoCommentResponse(
id=comment.id,
todo_item_id=comment.todo_item_id,
user_id=comment.user_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at
)
for comment in comments
]
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_todo_comments: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo comments: {str(e)}"
)
@router.get("/{todo_id}", response_model=TodoItemWithComments)
async def get_todo_item_with_comments(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""댓글이 포함된 할일 상세 조회"""
try:
# 할일 조회
result = await db.execute(
select(TodoItem).options(selectinload(TodoItem.comments))
.where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
# 댓글 데이터 구성
comments = [
TodoCommentResponse(
id=comment.id,
todo_item_id=comment.todo_item_id,
user_id=comment.user_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at
)
for comment in todo_item.comments
]
return TodoItemWithComments(
id=todo_item.id,
user_id=todo_item.user_id,
content=todo_item.content,
status=todo_item.status,
created_at=todo_item.created_at,
start_date=todo_item.start_date,
estimated_minutes=todo_item.estimated_minutes,
completed_at=todo_item.completed_at,
delayed_until=todo_item.delayed_until,
parent_id=todo_item.parent_id,
split_order=todo_item.split_order,
comment_count=len(comments),
comments=comments
)
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_todo_item_with_comments: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get todo item with comments: {str(e)}"
)

View File

@@ -9,7 +9,7 @@ import uvicorn
from .core.config import settings
from .core.database import init_db
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup, todos
from .api.routes import note_documents, note_links
@@ -62,6 +62,7 @@ app.include_router(note_links.router, prefix="/api", tags=["노트 링크"])
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
app.include_router(note_notes.router, prefix="/api", tags=["노트 메모"])
app.include_router(todos.router, prefix="/api", tags=["할일관리"])
@app.get("/")

View File

@@ -0,0 +1,63 @@
"""
할일관리 시스템 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
from ..core.database import Base
class TodoItem(Base):
"""할일 아이템"""
__tablename__ = "todo_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
# 기본 정보
content = Column(Text, nullable=False) # 할일 내용
status = Column(String(20), nullable=False, default="draft") # draft, scheduled, active, completed, delayed
# 시간 관리
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
start_date = Column(DateTime, nullable=True) # 시작 예정일
estimated_minutes = Column(Integer, nullable=True) # 예상 소요시간 (분)
completed_at = Column(DateTime, nullable=True)
delayed_until = Column(DateTime, nullable=True) # 지연된 경우 새로운 시작일
# 분할 관리
parent_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=True) # 분할된 할일의 부모
split_order = Column(Integer, nullable=True) # 분할 순서
# 관계
user = relationship("User", back_populates="todo_items")
comments = relationship("TodoComment", back_populates="todo_item", cascade="all, delete-orphan")
# 자기 참조 관계 (분할된 할일들)
subtasks = relationship("TodoItem", backref="parent_task", remote_side=[id])
def __repr__(self):
return f"<TodoItem(id={self.id}, content='{self.content[:50]}...', status='{self.status}')>"
class TodoComment(Base):
"""할일 댓글/메모"""
__tablename__ = "todo_comments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
todo_item_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
# 관계
todo_item = relationship("TodoItem", back_populates="comments")
user = relationship("User")
def __repr__(self):
return f"<TodoComment(id={self.id}, content='{self.content[:30]}...')>"

View File

@@ -45,6 +45,7 @@ class User(Base):
# 관계 (lazy loading을 위해 문자열로 참조)
memo_trees = relationship("MemoTree", back_populates="user", lazy="dynamic")
memo_nodes = relationship("MemoNode", back_populates="user", lazy="dynamic")
todo_items = relationship("TodoItem", back_populates="user", lazy="dynamic")
def __repr__(self):
return f"<User(email='{self.email}', full_name='{self.full_name}')>"

108
backend/src/schemas/todo.py Normal file
View File

@@ -0,0 +1,108 @@
"""
할일관리 시스템 스키마
"""
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from uuid import UUID
class TodoCommentBase(BaseModel):
content: str = Field(..., min_length=1, max_length=1000)
class TodoCommentCreate(TodoCommentBase):
pass
class TodoCommentUpdate(BaseModel):
content: Optional[str] = Field(None, min_length=1, max_length=1000)
class TodoCommentResponse(TodoCommentBase):
id: UUID
todo_item_id: UUID
user_id: UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TodoItemBase(BaseModel):
content: str = Field(..., min_length=1, max_length=2000)
class TodoItemCreate(TodoItemBase):
"""초기 할일 생성 (draft 상태)"""
pass
class TodoItemSchedule(BaseModel):
"""할일 일정 설정"""
start_date: datetime
estimated_minutes: int = Field(..., ge=1, le=120) # 1분~2시간
class TodoItemUpdate(BaseModel):
"""할일 수정"""
content: Optional[str] = Field(None, min_length=1, max_length=2000)
status: Optional[str] = Field(None, regex="^(draft|scheduled|active|completed|delayed)$")
start_date: Optional[datetime] = None
estimated_minutes: Optional[int] = Field(None, ge=1, le=120)
delayed_until: Optional[datetime] = None
class TodoItemDelay(BaseModel):
"""할일 지연"""
delayed_until: datetime
class TodoItemSplit(BaseModel):
"""할일 분할"""
subtasks: List[str] = Field(..., min_items=2, max_items=10)
estimated_minutes_per_task: List[int] = Field(..., min_items=2, max_items=10)
class TodoItemResponse(TodoItemBase):
id: UUID
user_id: UUID
status: str
created_at: datetime
start_date: Optional[datetime]
estimated_minutes: Optional[int]
completed_at: Optional[datetime]
delayed_until: Optional[datetime]
parent_id: Optional[UUID]
split_order: Optional[int]
# 댓글 수
comment_count: int = 0
class Config:
from_attributes = True
class TodoItemWithComments(TodoItemResponse):
"""댓글이 포함된 할일 응답"""
comments: List[TodoCommentResponse] = []
class TodoStats(BaseModel):
"""할일 통계"""
total_count: int
draft_count: int
scheduled_count: int
active_count: int
completed_count: int
delayed_count: int
completion_rate: float # 완료율 (%)
class TodoDashboard(BaseModel):
"""할일 대시보드"""
stats: TodoStats
today_todos: List[TodoItemResponse]
overdue_todos: List[TodoItemResponse]
upcoming_todos: List[TodoItemResponse]