from __future__ import annotations import csv from io import StringIO from datetime import UTC, datetime from functools import lru_cache from uuid import UUID, uuid4 from fastapi import APIRouter, Body, Depends, Path, Query, Response, status from fastapi.responses import JSONResponse from app.api.dto.tasks import ( ErrorDTO, TaskCreateDTO, TaskDTO, TaskListDTO, TaskListItemDTO, TaskQueryDTO, TaskUpdateDTO, ) from app.domain import InvalidTransitionError, complete_task, start_task from app.storage import JsonFileTaskRepository, StoredTask, create_task_repository router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @lru_cache(maxsize=1) def get_task_repository() -> JsonFileTaskRepository: return create_task_repository() def error_response( *, status_code: int, error: str, message: str, ) -> JSONResponse: payload = ErrorDTO(error=error, message=message) return JSONResponse( status_code=status_code, content=payload.model_dump(mode="json"), ) def parse_task_id(task_id: str) -> UUID | None: try: return UUID(task_id) except (TypeError, ValueError): return None def to_task_dto(task: StoredTask) -> TaskDTO: return TaskDTO.model_validate(task.model_dump(mode="python")) def to_task_list_item_dto(task: StoredTask) -> TaskListItemDTO: return TaskListItemDTO.model_validate(task.model_dump(mode="python")) def protect_csv_cell(value: str) -> str: stripped = value.lstrip() if stripped.startswith(("=", "+", "-", "@")): return f"'{value}" return value def build_tasks_csv(tasks: list[StoredTask]) -> str: buffer = StringIO() writer = csv.writer(buffer, lineterminator="\n") writer.writerow(["id", "title", "status", "created_at", "started_at", "done_at"]) for task in tasks: dto = to_task_list_item_dto(task) writer.writerow( [ str(task.id), protect_csv_cell(task.title), dto.status, task.created_at.isoformat(), task.started_at.isoformat() if task.started_at is not None else "", task.done_at.isoformat() if task.done_at is not None else "", ], ) return buffer.getvalue() def get_task_or_error( *, repo: JsonFileTaskRepository, task_id: str, ) -> tuple[StoredTask | None, JSONResponse | None]: parsed_task_id = parse_task_id(task_id) if parsed_task_id is None: return None, error_response( status_code=status.HTTP_400_BAD_REQUEST, error="invalid_id", message="Task id must be a valid UUID", ) task = repo.get_task(parsed_task_id) if task is None: return None, error_response( status_code=status.HTTP_404_NOT_FOUND, error="invalid_id", message="Task was not found", ) return task, None @router.get( "", response_model=TaskListDTO, responses={ 400: {"model": ErrorDTO}, }, ) def list_tasks( limit: int = Query(default=100, ge=1, le=1000), offset: int = Query(default=0, ge=0), status_filter: str | None = Query(default=None, alias="status"), search: str | None = Query(default=None, max_length=100), repo: JsonFileTaskRepository = Depends(get_task_repository), ) -> TaskListDTO | JSONResponse: try: query = TaskQueryDTO( limit=limit, offset=offset, status=status_filter, search=search, ) except Exception: return error_response( status_code=status.HTTP_400_BAD_REQUEST, error="invalid_payload", message="Invalid query parameters", ) tasks = repo.list_tasks() if query.status is not None: tasks = [ task for task in tasks if to_task_list_item_dto(task).status == query.status ] if query.search is not None: needle = query.search.casefold() tasks = [task for task in tasks if needle in task.title.casefold()] total = len(tasks) items = tasks[query.offset : query.offset + query.limit] return TaskListDTO( items=[to_task_list_item_dto(task) for task in items], total=total, limit=query.limit, offset=query.offset, ) @router.get("/export") def export_tasks_csv( repo: JsonFileTaskRepository = Depends(get_task_repository), ) -> Response: csv_content = build_tasks_csv(repo.list_tasks()) return Response( content=csv_content, media_type="text/csv", headers={ "Content-Disposition": 'attachment; filename="tasks.csv"', }, ) @router.get( "/{task_id}", response_model=TaskDTO, responses={ 400: {"model": ErrorDTO}, 404: {"model": ErrorDTO}, }, ) def get_task( task_id: str = Path(...), repo: JsonFileTaskRepository = Depends(get_task_repository), ) -> TaskDTO | JSONResponse: task, error = get_task_or_error(repo=repo, task_id=task_id) if error is not None: return error return to_task_dto(task) @router.post( "", response_model=TaskDTO, status_code=status.HTTP_201_CREATED, responses={ 400: {"model": ErrorDTO}, }, ) def create_task( payload: TaskCreateDTO = Body(...), repo: JsonFileTaskRepository = Depends(get_task_repository), ) -> TaskDTO | JSONResponse: now = datetime.now(UTC) task = StoredTask( id=uuid4(), title=payload.title, created_at=now, started_at=None, done_at=None, ) created_task = repo.create_task(task) return to_task_dto(created_task) @router.patch( "/{task_id}", response_model=TaskDTO, responses={ 400: {"model": ErrorDTO}, 404: {"model": ErrorDTO}, }, ) def update_task( task_id: str = Path(...), payload: TaskUpdateDTO = Body(...), repo: JsonFileTaskRepository = Depends(get_task_repository), ) -> TaskDTO | JSONResponse: existing_task, error = get_task_or_error(repo=repo, task_id=task_id) if error is not None: return error updated_task = StoredTask( id=existing_task.id, title=payload.title if payload.title is not None else existing_task.title, created_at=existing_task.created_at, started_at=existing_task.started_at, done_at=existing_task.done_at, ) repo.update_task(updated_task) return to_task_dto(updated_task) @router.delete( "/{task_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, responses={ 400: {"model": ErrorDTO}, 404: {"model": ErrorDTO}, }, ) def delete_task( task_id: str = Path(...), repo: JsonFileTaskRepository = Depends(get_task_repository), ) -> Response: parsed_task_id = parse_task_id(task_id) if parsed_task_id is None: return error_response( status_code=status.HTTP_400_BAD_REQUEST, error="invalid_id", message="Task id must be a valid UUID", ) deleted = repo.delete_task(parsed_task_id) if not deleted: return error_response( status_code=status.HTTP_404_NOT_FOUND, error="invalid_id", message="Task was not found", ) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post( "/{task_id}/start", response_model=TaskDTO, responses={ 400: {"model": ErrorDTO}, 404: {"model": ErrorDTO}, 409: {"model": ErrorDTO}, }, ) def start_task_endpoint( task_id: str = Path(...), repo: JsonFileTaskRepository = Depends(get_task_repository), ) -> TaskDTO | JSONResponse: task, error = get_task_or_error(repo=repo, task_id=task_id) if error is not None: return error try: updated_task = start_task(task) except InvalidTransitionError as exc: return error_response( status_code=status.HTTP_409_CONFLICT, error="invalid_transaction", message=str(exc), ) repo.update_task(updated_task) return to_task_dto(updated_task) @router.post( "/{task_id}/done", response_model=TaskDTO, responses={ 400: {"model": ErrorDTO}, 404: {"model": ErrorDTO}, 409: {"model": ErrorDTO}, }, ) def complete_task_endpoint( task_id: str = Path(...), repo: JsonFileTaskRepository = Depends(get_task_repository), ) -> TaskDTO | JSONResponse: task, error = get_task_or_error(repo=repo, task_id=task_id) if error is not None: return error try: updated_task = complete_task(task) except InvalidTransitionError as exc: return error_response( status_code=status.HTTP_409_CONFLICT, error="invalid_transaction", message=str(exc), ) repo.update_task(updated_task) return to_task_dto(updated_task)