feat: 체크리스트 이미지 미리보기 기능 구현

- 체크리스트 섹션에 이미지 썸네일 미리보기 추가 (16x16)
- 대시보드 상단 체크리스트 카드에 이미지 미리보기 기능 추가
- 이미지 클릭 시 전체 화면 모달로 확대 보기
- 백엔드 image_url 컬럼을 TEXT 타입으로 변경하여 Base64 이미지 지원
- 파일 업로드를 이미지만 지원하도록 단순화 (file_url, file_name 제거)
- 422 validation 오류 해결 및 상세 로깅 추가
- 체크리스트 렌더링 누락 문제 해결
This commit is contained in:
hyungi
2025-09-23 07:49:54 +09:00
parent 5c9ea92fb8
commit f80995c1ec
22 changed files with 2635 additions and 930 deletions

View File

@@ -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]

View File

@@ -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)

View File

@@ -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 조회에 실패했습니다."
)

View File

@@ -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)
)

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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("샘플 데이터 생성이 비활성화되었습니다.")
# 애플리케이션 종료 시 실행

View File

@@ -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}')>"

View File

@@ -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}')>"

View File

@@ -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

View File

@@ -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]