feat: 풀스택 할일관리 앱 구현 (통합 모달 + 간트차트)
- Backend: FastAPI + MongoDB + Redis (카테고리, 할일 CRUD, 파일 첨부, 검색, 대시보드) - Frontend: Next.js 15 + Tailwind + React Query + Zustand - 통합 TodoModal: 생성/수정 모달 통합, 탭 구조 (기본/태그와 첨부) - 간트차트: 카테고리별 할일 타임라인 시각화 - TodoCard: 제목/카테고리/우선순위/태그/첨부 한줄 표시 - Docker Compose 배포 (Frontend:3010, Backend:8010, MongoDB:27021, Redis:6391) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
9
backend/app/routers/__init__.py
Normal file
9
backend/app/routers/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from app.routers import todos, categories, tags, search, dashboard
|
||||
|
||||
__all__ = [
|
||||
"todos",
|
||||
"categories",
|
||||
"tags",
|
||||
"search",
|
||||
"dashboard",
|
||||
]
|
||||
55
backend/app/routers/categories.py
Normal file
55
backend/app/routers/categories.py
Normal file
@ -0,0 +1,55 @@
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
|
||||
from app.database import get_database, get_redis
|
||||
from app.models.category import (
|
||||
CategoryCreate,
|
||||
CategoryUpdate,
|
||||
CategoryResponse,
|
||||
)
|
||||
from app.services.category_service import CategoryService
|
||||
|
||||
router = APIRouter(prefix="/api/categories", tags=["categories"])
|
||||
|
||||
|
||||
def _get_service(
|
||||
db=Depends(get_database),
|
||||
redis=Depends(get_redis),
|
||||
) -> CategoryService:
|
||||
return CategoryService(db, redis)
|
||||
|
||||
|
||||
@router.get("", response_model=list[CategoryResponse])
|
||||
async def list_categories(
|
||||
service: CategoryService = Depends(_get_service),
|
||||
):
|
||||
"""F-008: 카테고리 목록 조회 (order 오름차순, todo_count 포함)"""
|
||||
return await service.list_categories()
|
||||
|
||||
|
||||
@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_category(
|
||||
data: CategoryCreate,
|
||||
service: CategoryService = Depends(_get_service),
|
||||
):
|
||||
"""F-007: 카테고리 생성"""
|
||||
return await service.create_category(data)
|
||||
|
||||
|
||||
@router.put("/{category_id}", response_model=CategoryResponse)
|
||||
async def update_category(
|
||||
category_id: str,
|
||||
data: CategoryUpdate,
|
||||
service: CategoryService = Depends(_get_service),
|
||||
):
|
||||
"""F-009: 카테고리 수정"""
|
||||
return await service.update_category(category_id, data)
|
||||
|
||||
|
||||
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_category(
|
||||
category_id: str,
|
||||
service: CategoryService = Depends(_get_service),
|
||||
):
|
||||
"""F-010: 카테고리 삭제 (해당 카테고리의 할일 category_id null 처리)"""
|
||||
await service.delete_category(category_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
17
backend/app/routers/dashboard.py
Normal file
17
backend/app/routers/dashboard.py
Normal file
@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.database import get_database, get_redis
|
||||
from app.models.todo import DashboardStats
|
||||
from app.services.dashboard_service import DashboardService
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStats)
|
||||
async def get_dashboard_stats(
|
||||
db=Depends(get_database),
|
||||
redis=Depends(get_redis),
|
||||
):
|
||||
"""F-018: 대시보드 통계 (Redis 캐싱, TTL 60초)"""
|
||||
service = DashboardService(db, redis)
|
||||
return await service.get_stats()
|
||||
19
backend/app/routers/search.py
Normal file
19
backend/app/routers/search.py
Normal file
@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.database import get_database
|
||||
from app.models.todo import SearchResponse
|
||||
from app.services.search_service import SearchService
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
|
||||
@router.get("", response_model=SearchResponse)
|
||||
async def search_todos(
|
||||
q: str = Query(..., min_length=1, max_length=200, description="검색 키워드"),
|
||||
page: int = Query(1, ge=1, description="페이지 번호"),
|
||||
limit: int = Query(20, ge=1, le=100, description="페이지당 항목 수"),
|
||||
db=Depends(get_database),
|
||||
):
|
||||
"""F-017: 전문 검색 (제목/내용/태그, text score 기준 정렬)"""
|
||||
service = SearchService(db)
|
||||
return await service.search(query=q, page=page, limit=limit)
|
||||
16
backend/app/routers/tags.py
Normal file
16
backend/app/routers/tags.py
Normal file
@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.database import get_database
|
||||
from app.models.todo import TagInfo
|
||||
from app.services.todo_service import TodoService
|
||||
|
||||
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TagInfo])
|
||||
async def list_tags(
|
||||
db=Depends(get_database),
|
||||
):
|
||||
"""F-013: 사용 중인 태그 목록 조회 (사용 횟수 포함, 내림차순)"""
|
||||
service = TodoService(db)
|
||||
return await service.get_tags()
|
||||
106
backend/app/routers/todos.py
Normal file
106
backend/app/routers/todos.py
Normal file
@ -0,0 +1,106 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response, status
|
||||
|
||||
from app.database import get_database, get_redis
|
||||
from app.models.todo import (
|
||||
TodoCreate,
|
||||
TodoUpdate,
|
||||
TodoResponse,
|
||||
TodoListResponse,
|
||||
BatchRequest,
|
||||
BatchResponse,
|
||||
ToggleResponse,
|
||||
)
|
||||
from app.services.todo_service import TodoService
|
||||
|
||||
router = APIRouter(prefix="/api/todos", tags=["todos"])
|
||||
|
||||
|
||||
def _get_service(
|
||||
db=Depends(get_database),
|
||||
redis=Depends(get_redis),
|
||||
) -> TodoService:
|
||||
return TodoService(db, redis)
|
||||
|
||||
|
||||
# 중요: /batch를 /{id} 보다 위에 등록
|
||||
@router.post("/batch", response_model=BatchResponse)
|
||||
async def batch_action(
|
||||
data: BatchRequest,
|
||||
service: TodoService = Depends(_get_service),
|
||||
):
|
||||
"""F-019~F-021: 일괄 작업 (완료/삭제/카테고리 변경)"""
|
||||
return await service.batch_action(data)
|
||||
|
||||
|
||||
@router.get("", response_model=TodoListResponse)
|
||||
async def list_todos(
|
||||
page: int = Query(1, ge=1, description="페이지 번호"),
|
||||
limit: int = Query(20, ge=1, le=100, description="페이지당 항목 수"),
|
||||
completed: Optional[bool] = Query(None, description="완료 상태 필터"),
|
||||
category_id: Optional[str] = Query(None, description="카테고리 필터"),
|
||||
priority: Optional[str] = Query(None, description="우선순위 필터 (high/medium/low)"),
|
||||
tag: Optional[str] = Query(None, description="태그 필터"),
|
||||
sort: str = Query("created_at", description="정렬 기준 (created_at/due_date/priority)"),
|
||||
order: str = Query("desc", description="정렬 방향 (asc/desc)"),
|
||||
service: TodoService = Depends(_get_service),
|
||||
):
|
||||
"""F-002: 할일 목록 조회 (필터/정렬/페이지네이션)"""
|
||||
return await service.list_todos(
|
||||
page=page,
|
||||
limit=limit,
|
||||
completed=completed,
|
||||
category_id=category_id,
|
||||
priority=priority,
|
||||
tag=tag,
|
||||
sort=sort,
|
||||
order=order,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TodoResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_todo(
|
||||
data: TodoCreate,
|
||||
service: TodoService = Depends(_get_service),
|
||||
):
|
||||
"""F-001: 할일 생성"""
|
||||
return await service.create_todo(data)
|
||||
|
||||
|
||||
@router.get("/{todo_id}", response_model=TodoResponse)
|
||||
async def get_todo(
|
||||
todo_id: str,
|
||||
service: TodoService = Depends(_get_service),
|
||||
):
|
||||
"""F-003: 할일 상세 조회"""
|
||||
return await service.get_todo(todo_id)
|
||||
|
||||
|
||||
@router.put("/{todo_id}", response_model=TodoResponse)
|
||||
async def update_todo(
|
||||
todo_id: str,
|
||||
data: TodoUpdate,
|
||||
service: TodoService = Depends(_get_service),
|
||||
):
|
||||
"""F-004: 할일 수정"""
|
||||
return await service.update_todo(todo_id, data)
|
||||
|
||||
|
||||
@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_todo(
|
||||
todo_id: str,
|
||||
service: TodoService = Depends(_get_service),
|
||||
):
|
||||
"""F-005: 할일 삭제"""
|
||||
await service.delete_todo(todo_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.patch("/{todo_id}/toggle", response_model=ToggleResponse)
|
||||
async def toggle_todo(
|
||||
todo_id: str,
|
||||
service: TodoService = Depends(_get_service),
|
||||
):
|
||||
"""F-006: 할일 완료 토글"""
|
||||
return await service.toggle_todo(todo_id)
|
||||
56
backend/app/routers/uploads.py
Normal file
56
backend/app/routers/uploads.py
Normal file
@ -0,0 +1,56 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.database import get_database
|
||||
from app.models.todo import Attachment
|
||||
from app.services.file_service import FileService
|
||||
|
||||
router = APIRouter(prefix="/api/todos", tags=["attachments"])
|
||||
|
||||
|
||||
def _get_service(db=Depends(get_database)) -> FileService:
|
||||
return FileService(db)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{todo_id}/attachments",
|
||||
response_model=list[Attachment],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def upload_attachments(
|
||||
todo_id: str,
|
||||
files: List[UploadFile] = File(...),
|
||||
service: FileService = Depends(_get_service),
|
||||
):
|
||||
"""파일 업로드 (최대 5개, 파일당 10MB)"""
|
||||
return await service.upload_files(todo_id, files)
|
||||
|
||||
|
||||
@router.get("/{todo_id}/attachments/{attachment_id}/download")
|
||||
async def download_attachment(
|
||||
todo_id: str,
|
||||
attachment_id: str,
|
||||
service: FileService = Depends(_get_service),
|
||||
):
|
||||
"""파일 다운로드"""
|
||||
file_path, filename = await service.get_file_info(todo_id, attachment_id)
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{todo_id}/attachments/{attachment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_attachment(
|
||||
todo_id: str,
|
||||
attachment_id: str,
|
||||
service: FileService = Depends(_get_service),
|
||||
):
|
||||
"""첨부파일 삭제"""
|
||||
await service.delete_attachment(todo_id, attachment_id)
|
||||
Reference in New Issue
Block a user