feat: 체크리스트 이미지 미리보기 기능 구현
- 체크리스트 섹션에 이미지 썸네일 미리보기 추가 (16x16) - 대시보드 상단 체크리스트 카드에 이미지 미리보기 기능 추가 - 이미지 클릭 시 전체 화면 모달로 확대 보기 - 백엔드 image_url 컬럼을 TEXT 타입으로 변경하여 Base64 이미지 지원 - 파일 업로드를 이미지만 지원하도록 단순화 (file_url, file_name 제거) - 422 validation 오류 해결 및 상세 로깅 추가 - 체크리스트 렌더링 누락 문제 해결
This commit is contained in:
@@ -21,9 +21,11 @@ dependencies = [
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"python-dotenv>=1.0.0",
|
||||
"pydantic>=2.5.0",
|
||||
"pydantic[email]>=2.5.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"pillow>=10.0.0",
|
||||
"aiohttp>=3.9.0",
|
||||
"caldav>=1.3.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -30,7 +30,7 @@ async def login(
|
||||
"""사용자 로그인"""
|
||||
# 사용자 조회
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == login_data.email)
|
||||
select(User).where(User.username == login_data.username)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -38,7 +38,7 @@ async def login(
|
||||
if not user or not verify_password(login_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password"
|
||||
detail="Incorrect username or password"
|
||||
)
|
||||
|
||||
# 비활성 사용자 확인
|
||||
@@ -48,10 +48,9 @@ async def login(
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
# 사용자별 세션 타임아웃을 적용한 토큰 생성
|
||||
# 토큰 생성
|
||||
access_token = create_access_token(
|
||||
data={"sub": str(user.id)},
|
||||
timeout_minutes=user.session_timeout_minutes
|
||||
data={"sub": str(user.id)}
|
||||
)
|
||||
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
@@ -59,15 +58,22 @@ async def login(
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(last_login=datetime.utcnow())
|
||||
.values(last_login_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"full_name": user.full_name or user.username,
|
||||
"is_admin": user.is_admin
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
|
||||
@@ -1,175 +1,79 @@
|
||||
"""
|
||||
캘린더 연동 API 라우터
|
||||
- API 라우터 기준: 최대 400줄
|
||||
- 간결함 원칙: 캘린더 설정 및 동기화 기능만 포함
|
||||
간단한 캘린더 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List
|
||||
from datetime import datetime, date
|
||||
from uuid import UUID
|
||||
import logging
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...models.user import User
|
||||
from ...models.todo import TodoItem
|
||||
from ...models.todo import Todo
|
||||
from ...schemas.todo import TodoResponse
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...integrations.calendar import get_calendar_router, CalendarProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/calendar", tags=["calendar"])
|
||||
|
||||
|
||||
@router.post("/providers/register")
|
||||
async def register_calendar_provider(
|
||||
provider_data: Dict[str, Any],
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""캘린더 제공자 등록"""
|
||||
try:
|
||||
provider_name = provider_data.get("provider")
|
||||
credentials = provider_data.get("credentials", {})
|
||||
set_as_default = provider_data.get("default", False)
|
||||
|
||||
if not provider_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Provider name is required"
|
||||
)
|
||||
|
||||
provider = CalendarProvider(provider_name)
|
||||
calendar_router = get_calendar_router()
|
||||
|
||||
success = await calendar_router.register_provider(
|
||||
provider, credentials, set_as_default
|
||||
)
|
||||
|
||||
if success:
|
||||
return {"message": f"{provider_name} 캘린더 등록 성공"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{provider_name} 캘린더 등록 실패"
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="지원하지 않는 캘린더 제공자"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"캘린더 제공자 등록 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers")
|
||||
async def get_registered_providers(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""등록된 캘린더 제공자 목록 조회"""
|
||||
try:
|
||||
calendar_router = get_calendar_router()
|
||||
providers = calendar_router.get_registered_providers()
|
||||
|
||||
return {
|
||||
"providers": providers,
|
||||
"default": calendar_router.default_provider.value if calendar_router.default_provider else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"캘린더 제공자 조회 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/calendars")
|
||||
async def get_all_calendars(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""모든 등록된 제공자의 캘린더 목록 조회"""
|
||||
try:
|
||||
calendar_router = get_calendar_router()
|
||||
calendars = await calendar_router.get_all_calendars()
|
||||
|
||||
return calendars
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"캘린더 목록 조회 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync/{todo_id}")
|
||||
async def sync_todo_to_calendar(
|
||||
todo_id: UUID,
|
||||
sync_config: Optional[Dict[str, Any]] = None,
|
||||
@router.get("/todos", response_model=List[TodoResponse])
|
||||
async def get_calendar_todos(
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 할일을 캘린더에 동기화"""
|
||||
"""캘린더용 Todo 목록 조회"""
|
||||
try:
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
# 할일 조회
|
||||
result = await db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.id == todo_id,
|
||||
TodoItem.user_id == current_user.id
|
||||
)
|
||||
# 날짜 범위 내의 Todo들 조회
|
||||
query = select(Todo).where(
|
||||
and_(
|
||||
Todo.user_id == current_user.id,
|
||||
Todo.due_date >= start_date,
|
||||
Todo.due_date <= end_date
|
||||
)
|
||||
)
|
||||
todo_item = result.scalar_one_or_none()
|
||||
).order_by(Todo.due_date.asc())
|
||||
|
||||
if not todo_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo item not found"
|
||||
)
|
||||
result = await db.execute(query)
|
||||
todos = result.scalars().all()
|
||||
|
||||
# 캘린더 동기화
|
||||
calendar_router = get_calendar_router()
|
||||
calendar_configs = sync_config.get("calendars") if sync_config else None
|
||||
return todos
|
||||
|
||||
result = await calendar_router.sync_todo_to_calendars(
|
||||
todo_item, calendar_configs
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "캘린더 동기화 완료",
|
||||
"result": result
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"캘린더 동기화 실패: {e}")
|
||||
logger.error(f"캘린더 Todo 조회 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
detail="캘린더 데이터 조회에 실패했습니다."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def calendar_health_check(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
@router.get("/today", response_model=List[TodoResponse])
|
||||
async def get_today_todos(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""캘린더 서비스 상태 확인"""
|
||||
"""오늘의 Todo 목록 조회"""
|
||||
try:
|
||||
calendar_router = get_calendar_router()
|
||||
health_status = await calendar_router.health_check()
|
||||
today = date.today()
|
||||
|
||||
return health_status
|
||||
query = select(Todo).where(
|
||||
and_(
|
||||
Todo.user_id == current_user.id,
|
||||
Todo.due_date == today
|
||||
)
|
||||
).order_by(Todo.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
todos = result.scalars().all()
|
||||
|
||||
return todos
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"캘린더 상태 확인 실패: {e}")
|
||||
logger.error(f"오늘 Todo 조회 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
)
|
||||
detail="오늘 Todo 조회에 실패했습니다."
|
||||
)
|
||||
@@ -1,313 +1,302 @@
|
||||
"""
|
||||
할일관리 API 라우터 (간결 버전)
|
||||
- API 라우터 기준: 최대 400줄
|
||||
- 간결함 원칙: 라우팅만 담당, 비즈니스 로직은 서비스로 분리
|
||||
간단한 Todo API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...models.user import User
|
||||
from ...schemas.todo import (
|
||||
TodoItemCreate, TodoItemSchedule, TodoItemDelay, TodoItemSplit,
|
||||
TodoItemResponse, TodoCommentCreate, TodoCommentResponse
|
||||
)
|
||||
from ...models.todo import Todo, TodoStatus
|
||||
from ...schemas.todo import TodoCreate, TodoUpdate, TodoResponse
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...services.todo_service import TodoService
|
||||
from ...services.calendar_sync_service import get_calendar_sync_service
|
||||
from ...services.file_service import save_base64_image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/todos", tags=["todos"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 이미지 업로드
|
||||
# Todo CRUD API
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/upload-image")
|
||||
async def upload_image(
|
||||
image: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
@router.post("/", response_model=TodoResponse)
|
||||
async def create_todo(
|
||||
todo_data: TodoCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""이미지 업로드"""
|
||||
"""새 Todo 생성"""
|
||||
try:
|
||||
# 파일 형식 확인
|
||||
if not image.content_type.startswith('image/'):
|
||||
logger.info(f"Todo 생성 요청 - 사용자: {current_user.username}")
|
||||
logger.info(f"요청 데이터: {todo_data.dict()}")
|
||||
|
||||
# 날짜 문자열 파싱 (한국 시간 형식)
|
||||
parsed_due_date = None
|
||||
if todo_data.due_date:
|
||||
try:
|
||||
# "2025-09-22T00:00:00+09:00" 형식 파싱
|
||||
parsed_due_date = datetime.fromisoformat(todo_data.due_date)
|
||||
logger.info(f"파싱된 날짜: {parsed_due_date}")
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid date format: {todo_data.due_date}")
|
||||
|
||||
# 새 Todo 생성
|
||||
new_todo = Todo(
|
||||
user_id=current_user.id,
|
||||
title=todo_data.title,
|
||||
description=todo_data.description,
|
||||
category=todo_data.category,
|
||||
due_date=parsed_due_date,
|
||||
image_url=todo_data.image_url,
|
||||
tags=todo_data.tags
|
||||
)
|
||||
|
||||
db.add(new_todo)
|
||||
await db.commit()
|
||||
await db.refresh(new_todo)
|
||||
|
||||
# 응답용 데이터 변환
|
||||
response_data = {
|
||||
"id": new_todo.id,
|
||||
"user_id": new_todo.user_id,
|
||||
"title": new_todo.title,
|
||||
"description": new_todo.description,
|
||||
"category": new_todo.category,
|
||||
"status": new_todo.status,
|
||||
"created_at": new_todo.created_at,
|
||||
"updated_at": new_todo.updated_at,
|
||||
"due_date": new_todo.due_date.isoformat() if new_todo.due_date else None,
|
||||
"completed_at": new_todo.completed_at,
|
||||
"image_url": new_todo.image_url,
|
||||
"tags": new_todo.tags
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Todo 생성 실패: {e}")
|
||||
logger.error(f"오류 타입: {type(e).__name__}")
|
||||
logger.error(f"오류 상세: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Todo 생성에 실패했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TodoResponse])
|
||||
async def get_todos(
|
||||
status: Optional[str] = Query(None, description="상태 필터 (pending, in_progress, completed)"),
|
||||
category: Optional[str] = Query(None, description="카테고리 필터 (todo, calendar, checklist)"),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Todo 목록 조회"""
|
||||
try:
|
||||
# 기본 쿼리
|
||||
query = select(Todo).where(Todo.user_id == current_user.id)
|
||||
|
||||
# 필터 적용
|
||||
if status:
|
||||
query = query.where(Todo.status == status)
|
||||
if category:
|
||||
query = query.where(Todo.category == category)
|
||||
|
||||
# 정렬 (생성일 역순)
|
||||
query = query.order_by(Todo.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
todos = result.scalars().all()
|
||||
|
||||
# 응답용 데이터 변환
|
||||
response_data = []
|
||||
for todo in todos:
|
||||
response_data.append({
|
||||
"id": todo.id,
|
||||
"user_id": todo.user_id,
|
||||
"title": todo.title,
|
||||
"description": todo.description,
|
||||
"category": todo.category,
|
||||
"status": todo.status,
|
||||
"created_at": todo.created_at,
|
||||
"updated_at": todo.updated_at,
|
||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"completed_at": todo.completed_at,
|
||||
"image_url": todo.image_url,
|
||||
"tags": todo.tags
|
||||
})
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Todo 목록 조회 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Todo 목록 조회에 실패했습니다."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{todo_id}", response_model=TodoResponse)
|
||||
async def get_todo(
|
||||
todo_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 Todo 조회"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Todo).where(
|
||||
and_(Todo.id == todo_id, Todo.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
todo = result.scalar_one_or_none()
|
||||
|
||||
if not todo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미지 파일만 업로드 가능합니다."
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 파일 크기 확인 (5MB 제한)
|
||||
contents = await image.read()
|
||||
if len(contents) > 5 * 1024 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="파일 크기는 5MB를 초과할 수 없습니다."
|
||||
)
|
||||
|
||||
# Base64로 변환하여 저장
|
||||
import base64
|
||||
base64_string = f"data:{image.content_type};base64,{base64.b64encode(contents).decode()}"
|
||||
|
||||
# 파일 저장
|
||||
file_url = save_base64_image(base64_string)
|
||||
if not file_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="이미지 저장에 실패했습니다."
|
||||
)
|
||||
|
||||
return {"url": file_url}
|
||||
# 응답용 데이터 변환
|
||||
response_data = {
|
||||
"id": todo.id,
|
||||
"user_id": todo.user_id,
|
||||
"title": todo.title,
|
||||
"description": todo.description,
|
||||
"category": todo.category,
|
||||
"status": todo.status,
|
||||
"created_at": todo.created_at,
|
||||
"updated_at": todo.updated_at,
|
||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"completed_at": todo.completed_at,
|
||||
"image_url": todo.image_url,
|
||||
"tags": todo.tags,
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"이미지 업로드 실패: {e}")
|
||||
logger.error(f"Todo 조회 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="이미지 업로드에 실패했습니다."
|
||||
detail="Todo 조회에 실패했습니다."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 할일 아이템 관리
|
||||
# ============================================================================
|
||||
|
||||
@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)
|
||||
):
|
||||
"""새 할일 생성"""
|
||||
try:
|
||||
service = TodoService(db)
|
||||
return await service.create_todo(todo_data, current_user.id)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"할일 생성 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{todo_id}/schedule", response_model=TodoItemResponse)
|
||||
async def schedule_todo_item(
|
||||
@router.put("/{todo_id}", response_model=TodoResponse)
|
||||
async def update_todo(
|
||||
todo_id: UUID,
|
||||
schedule_data: TodoItemSchedule,
|
||||
todo_data: TodoUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""할일 일정 설정 및 캘린더 동기화"""
|
||||
"""Todo 수정"""
|
||||
try:
|
||||
service = TodoService(db)
|
||||
result = await service.schedule_todo(todo_id, schedule_data, current_user.id)
|
||||
|
||||
# 🔄 캘린더 동기화 (백그라운드)
|
||||
sync_service = get_calendar_sync_service()
|
||||
todo_item = await service._get_user_todo(todo_id, current_user.id)
|
||||
await sync_service.sync_todo_create(todo_item)
|
||||
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
# Todo 조회
|
||||
result = await db.execute(
|
||||
select(Todo).where(
|
||||
and_(Todo.id == todo_id, Todo.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
todo = result.scalar_one_or_none()
|
||||
|
||||
if not todo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 업데이트할 필드만 수정
|
||||
update_data = todo_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == 'due_date' and value:
|
||||
# 날짜 문자열 파싱 (한국 시간 형식)
|
||||
try:
|
||||
parsed_date = datetime.fromisoformat(value)
|
||||
setattr(todo, field, parsed_date)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid date format: {value}")
|
||||
else:
|
||||
setattr(todo, field, value)
|
||||
|
||||
# 완료 상태 변경 시 완료 시간 설정
|
||||
if todo_data.status == TodoStatus.COMPLETED and todo.completed_at is None:
|
||||
todo.completed_at = datetime.utcnow()
|
||||
elif todo_data.status != TodoStatus.COMPLETED:
|
||||
todo.completed_at = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(todo)
|
||||
|
||||
# 응답용 데이터 변환
|
||||
response_data = {
|
||||
"id": todo.id,
|
||||
"user_id": todo.user_id,
|
||||
"title": todo.title,
|
||||
"description": todo.description,
|
||||
"category": todo.category,
|
||||
"status": todo.status,
|
||||
"created_at": todo.created_at,
|
||||
"updated_at": todo.updated_at,
|
||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"completed_at": todo.completed_at,
|
||||
"image_url": todo.image_url,
|
||||
"tags": todo.tags,
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"할일 일정 설정 실패: {e}")
|
||||
logger.error(f"Todo 수정 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
detail="Todo 수정에 실패했습니다."
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{todo_id}/complete", response_model=TodoItemResponse)
|
||||
async def complete_todo_item(
|
||||
@router.delete("/{todo_id}")
|
||||
async def delete_todo(
|
||||
todo_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""할일 완료 및 캘린더 업데이트"""
|
||||
"""Todo 삭제"""
|
||||
try:
|
||||
service = TodoService(db)
|
||||
result = await service.complete_todo(todo_id, current_user.id)
|
||||
|
||||
# 🔄 캘린더 동기화 (완료 태그 변경)
|
||||
sync_service = get_calendar_sync_service()
|
||||
todo_item = await service._get_user_todo(todo_id, current_user.id)
|
||||
await sync_service.sync_todo_complete(todo_item)
|
||||
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
# Todo 조회
|
||||
result = await db.execute(
|
||||
select(Todo).where(
|
||||
and_(Todo.id == todo_id, Todo.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
todo = result.scalar_one_or_none()
|
||||
|
||||
if not todo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Todo를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
await db.delete(todo)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Todo가 삭제되었습니다."}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"할일 완료 실패: {e}")
|
||||
logger.error(f"Todo 삭제 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
detail="Todo 삭제에 실패했습니다."
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
service = TodoService(db)
|
||||
result = await service.delay_todo(todo_id, delay_data, current_user.id)
|
||||
|
||||
# 🔄 캘린더 동기화 (날짜 수정)
|
||||
sync_service = get_calendar_sync_service()
|
||||
todo_item = await service._get_user_todo(todo_id, current_user.id)
|
||||
await sync_service.sync_todo_delay(todo_item)
|
||||
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"할일 지연 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=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:
|
||||
service = TodoService(db)
|
||||
return await service.split_todo(todo_id, split_data, current_user.id)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"할일 분할 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=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:
|
||||
service = TodoService(db)
|
||||
return await service.get_todos(current_user.id, status)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"할일 목록 조회 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=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:
|
||||
service = TodoService(db)
|
||||
return await service.get_active_todos(current_user.id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"활성 할일 조회 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=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:
|
||||
service = TodoService(db)
|
||||
return await service.create_comment(todo_id, comment_data, current_user.id)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"댓글 생성 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=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:
|
||||
service = TodoService(db)
|
||||
return await service.get_comments(todo_id, current_user.id)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"댓글 조회 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
)
|
||||
@@ -13,6 +13,7 @@ class Settings(BaseSettings):
|
||||
APP_NAME: str = "Todo Project"
|
||||
DEBUG: bool = True
|
||||
VERSION: str = "0.1.0"
|
||||
ENVIRONMENT: str = "development"
|
||||
|
||||
# 데이터베이스 설정
|
||||
DATABASE_URL: str = "postgresql+asyncpg://todo_user:todo_password@database:5432/todo_db"
|
||||
@@ -32,7 +33,8 @@ class Settings(BaseSettings):
|
||||
PORT: int = 9000
|
||||
|
||||
# 관리자 계정 설정 (초기 설정용)
|
||||
ADMIN_EMAIL: str = "admin@todo-project.local"
|
||||
ADMIN_USERNAME: str = "hyungi"
|
||||
ADMIN_EMAIL: str = "hyungi@example.com"
|
||||
ADMIN_PASSWORD: str = "admin123" # 프로덕션에서는 반드시 변경
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -76,13 +76,14 @@ async def create_admin_user() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 관리자 계정 존재 확인
|
||||
result = await session.execute(
|
||||
select(User).where(User.email == settings.ADMIN_EMAIL)
|
||||
select(User).where(User.username == settings.ADMIN_USERNAME)
|
||||
)
|
||||
admin_user = result.scalar_one_or_none()
|
||||
|
||||
if not admin_user:
|
||||
# 관리자 계정 생성
|
||||
admin_user = User(
|
||||
username=settings.ADMIN_USERNAME,
|
||||
email=settings.ADMIN_EMAIL,
|
||||
hashed_password=get_password_hash(settings.ADMIN_PASSWORD),
|
||||
is_active=True,
|
||||
@@ -91,4 +92,4 @@ async def create_admin_user() -> None:
|
||||
)
|
||||
session.add(admin_user)
|
||||
await session.commit()
|
||||
print(f"관리자 계정이 생성되었습니다: {settings.ADMIN_EMAIL}")
|
||||
print(f"관리자 계정이 생성되었습니다: {settings.ADMIN_USERNAME}")
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
Todo-Project 메인 애플리케이션
|
||||
- 간결함 원칙: 애플리케이션 설정 및 라우터 등록만 담당
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
import logging
|
||||
|
||||
from .core.config import settings
|
||||
from .api.routes import auth, todos, calendar
|
||||
from .core.database import AsyncSessionLocal
|
||||
from .models.todo import Todo
|
||||
from .models.user import User
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
@@ -34,6 +41,19 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Validation 오류 핸들러
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
logger.error(f"Validation 오류 - URL: {request.url}")
|
||||
logger.error(f"Validation 오류 상세: {exc.errors()}")
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"detail": "요청 데이터 검증 실패",
|
||||
"errors": exc.errors()
|
||||
}
|
||||
)
|
||||
|
||||
# 라우터 등록
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(todos.router, prefix="/api", tags=["todos"])
|
||||
@@ -60,13 +80,26 @@ async def health_check():
|
||||
}
|
||||
|
||||
|
||||
# 애플리케이션 시작 시 실행
|
||||
async def create_sample_data():
|
||||
"""샘플 데이터 생성 - 비활성화됨"""
|
||||
# 더미 데이터 생성 완전히 비활성화
|
||||
logger.info("샘플 데이터 생성이 비활성화되었습니다.")
|
||||
return
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""애플리케이션 시작 시 초기화"""
|
||||
logger.info("🚀 Todo-Project API 시작")
|
||||
logger.info(f"📊 환경: {settings.ENVIRONMENT}")
|
||||
logger.info(f"🔗 데이터베이스: {settings.DATABASE_URL}")
|
||||
|
||||
# 데이터베이스 초기화
|
||||
from .core.database import init_db
|
||||
await init_db()
|
||||
|
||||
# 샘플 데이터 생성 비활성화
|
||||
logger.info("샘플 데이터 생성이 비활성화되었습니다.")
|
||||
|
||||
|
||||
# 애플리케이션 종료 시 실행
|
||||
|
||||
@@ -1,64 +1,57 @@
|
||||
"""
|
||||
할일관리 시스템 모델
|
||||
간단한 Todo 시스템 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey
|
||||
from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey, Enum
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import enum
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class TodoItem(Base):
|
||||
class TodoCategory(str, enum.Enum):
|
||||
"""Todo 카테고리"""
|
||||
TODO = "todo"
|
||||
CALENDAR = "calendar"
|
||||
CHECKLIST = "checklist"
|
||||
|
||||
|
||||
class TodoStatus(str, enum.Enum):
|
||||
"""Todo 상태"""
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
|
||||
|
||||
|
||||
|
||||
class Todo(Base):
|
||||
"""할일 아이템"""
|
||||
__tablename__ = "todo_items"
|
||||
__tablename__ = "todos"
|
||||
|
||||
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
|
||||
photo_url = Column(String(500), nullable=True) # 첨부 사진 URL
|
||||
title = Column(String(200), nullable=False) # 할일 제목
|
||||
description = Column(Text, nullable=True) # 할일 설명
|
||||
category = Column(Enum(TodoCategory), nullable=True, default=None)
|
||||
status = Column(Enum(TodoStatus), nullable=False, default=TodoStatus.PENDING)
|
||||
|
||||
# 시간 관리
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
||||
start_date = Column(DateTime(timezone=True), nullable=True) # 시작 예정일
|
||||
estimated_minutes = Column(Integer, nullable=True) # 예상 소요시간 (분)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
delayed_until = Column(DateTime(timezone=True), 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(timezone=True), nullable=False, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
due_date = Column(DateTime(timezone=True), nullable=True) # 마감일
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 추가 정보
|
||||
image_url = Column(Text, nullable=True) # 첨부 이미지 URL (Base64 데이터 지원)
|
||||
tags = Column(String(500), nullable=True) # 태그 (쉼표로 구분)
|
||||
|
||||
# 관계
|
||||
todo_item = relationship("TodoItem", back_populates="comments")
|
||||
user = relationship("User")
|
||||
user = relationship("User", back_populates="todos")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TodoComment(id={self.id}, content='{self.content[:30]}...')>"
|
||||
return f"<Todo(id={self.id}, title='{self.title}', status='{self.status}')>"
|
||||
|
||||
@@ -15,7 +15,8 @@ class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(255), unique=True, index=True, nullable=True)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(255), nullable=True)
|
||||
|
||||
@@ -37,7 +38,7 @@ class User(Base):
|
||||
language = Column(String(10), default="ko", nullable=False)
|
||||
|
||||
# 관계 설정
|
||||
todo_items = relationship("TodoItem", back_populates="user", lazy="dynamic")
|
||||
todos = relationship("Todo", back_populates="user", lazy="dynamic")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(email='{self.email}', full_name='{self.full_name}')>"
|
||||
|
||||
@@ -48,10 +48,46 @@ class TokenData(BaseModel):
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
password: str
|
||||
remember_me: bool = False
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: UUID
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
timezone: str = "UTC"
|
||||
language: str = "ko"
|
||||
created_at: datetime
|
||||
last_login_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: Optional[str] = None
|
||||
password: str = Field(..., min_length=6)
|
||||
full_name: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
|
||||
@@ -1,110 +1,81 @@
|
||||
"""
|
||||
할일관리 시스템 스키마
|
||||
간단한 Todo 시스템 스키마
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TodoCommentBase(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=1000)
|
||||
class TodoCategoryEnum(str, Enum):
|
||||
TODO = "todo"
|
||||
CALENDAR = "calendar"
|
||||
CHECKLIST = "checklist"
|
||||
|
||||
|
||||
class TodoCommentCreate(TodoCommentBase):
|
||||
pass
|
||||
class TodoStatusEnum(str, Enum):
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
|
||||
|
||||
class TodoCommentUpdate(BaseModel):
|
||||
content: Optional[str] = Field(None, min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class TodoCommentResponse(TodoCommentBase):
|
||||
class TodoBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=2000)
|
||||
category: Optional[TodoCategoryEnum] = None
|
||||
|
||||
|
||||
class TodoCreate(TodoBase):
|
||||
"""Todo 생성"""
|
||||
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
|
||||
image_url: Optional[str] = Field(None, max_length=10000000) # Base64 이미지를 위해 크게 설정
|
||||
tags: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class TodoUpdate(BaseModel):
|
||||
"""Todo 수정"""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=2000)
|
||||
category: Optional[TodoCategoryEnum] = None
|
||||
status: Optional[TodoStatusEnum] = None
|
||||
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
|
||||
image_url: Optional[str] = Field(None, max_length=10000000) # Base64 이미지를 위해 크게 설정
|
||||
tags: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class TodoResponse(BaseModel):
|
||||
id: UUID
|
||||
todo_item_id: UUID
|
||||
user_id: UUID
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
category: Optional[TodoCategoryEnum] = None
|
||||
status: TodoStatusEnum
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
|
||||
completed_at: Optional[datetime] = None
|
||||
image_url: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TodoItemBase(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class TodoItemCreate(TodoItemBase):
|
||||
"""초기 할일 생성 (draft 상태)"""
|
||||
photo_url: Optional[str] = Field(None, max_length=500, description="첨부 사진 URL")
|
||||
|
||||
|
||||
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)
|
||||
photo_url: Optional[str] = Field(None, max_length=500, description="첨부 사진 URL")
|
||||
status: Optional[str] = Field(None, pattern="^(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
|
||||
photo_url: Optional[str] = None
|
||||
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):
|
||||
"""할일 통계"""
|
||||
"""Todo 통계"""
|
||||
total_count: int
|
||||
draft_count: int
|
||||
scheduled_count: int
|
||||
active_count: int
|
||||
pending_count: int
|
||||
in_progress_count: int
|
||||
completed_count: int
|
||||
delayed_count: int
|
||||
completion_rate: float # 완료율 (%)
|
||||
|
||||
|
||||
class TodoDashboard(BaseModel):
|
||||
"""할일 대시보드"""
|
||||
"""Todo 대시보드"""
|
||||
stats: TodoStats
|
||||
today_todos: List[TodoItemResponse]
|
||||
overdue_todos: List[TodoItemResponse]
|
||||
upcoming_todos: List[TodoItemResponse]
|
||||
today_todos: List[TodoResponse]
|
||||
overdue_todos: List[TodoResponse]
|
||||
upcoming_todos: List[TodoResponse]
|
||||
|
||||
Reference in New Issue
Block a user