Files
home_hub/backend/app/api/todos.py
T
gilles b5f0453cdd fix(todos): datetime query params typés FastAPI, ORDER BY déterministe
- Changer due_after/due_before de str | None vers datetime | None pour typage FastAPI
- FastAPI parse et valide automatiquement, retourne 422 si format invalide (pas 500)
- Supprimer le parsing manuel datetime.fromisoformat() qui levait ValueError brute
- Ajouter ORDER BY déterministe: due_date ASC NULLS LAST, created_at DESC
  évite les réordonnances aléatoires entre requêtes PostgreSQL

Tests: 15/15 passent ✓

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:02:37 +02:00

112 lines
3.3 KiB
Python

import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.todos import TodoItem
from app.schemas.todos import TodoCreate, TodoUpdate, PostponeRequest, TodoResponse
router = APIRouter()
@router.get("/", response_model=list[TodoResponse])
async def list_todos(
domain: str | None = None,
status: str | None = "pending",
priority: str | None = None,
tag: str | None = None,
due_after: datetime | None = None,
due_before: datetime | None = None,
limit: int = 200,
session: AsyncSession = Depends(get_session),
):
conditions = []
if domain:
conditions.append(TodoItem.domain == domain)
if status:
conditions.append(TodoItem.status == status)
if priority:
conditions.append(TodoItem.priority == priority)
if tag:
conditions.append(TodoItem.tags.contains([tag]))
if due_after:
conditions.append(TodoItem.due_date >= due_after)
if due_before:
conditions.append(TodoItem.due_date <= due_before)
stmt = select(TodoItem)
if conditions:
stmt = stmt.where(and_(*conditions))
stmt = stmt.order_by(TodoItem.due_date.asc().nulls_last(), TodoItem.created_at.desc())
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
@router.post("/", response_model=TodoResponse, status_code=201)
async def create_todo(
payload: TodoCreate,
session: AsyncSession = Depends(get_session),
):
item = TodoItem(**payload.model_dump())
session.add(item)
await session.commit()
await session.refresh(item)
return item
@router.patch("/{item_id}", response_model=TodoResponse)
async def update_todo(
item_id: uuid.UUID,
payload: TodoUpdate,
session: AsyncSession = Depends(get_session),
):
item = await session.get(TodoItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Tâche introuvable")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
item.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(item)
return item
@router.delete("/{item_id}", status_code=204)
async def delete_todo(
item_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
):
item = await session.get(TodoItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Tâche introuvable")
await session.delete(item)
await session.commit()
return Response(status_code=204)
@router.post("/{item_id}/postpone", response_model=TodoResponse)
async def postpone_todo(
item_id: uuid.UUID,
payload: PostponeRequest,
session: AsyncSession = Depends(get_session),
):
item = await session.get(TodoItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Tâche introuvable")
now = datetime.now(timezone.utc)
base = item.due_date if item.due_date else now
item.due_date = base + timedelta(days=payload.days)
item.postponed_count += 1
item.updated_at = now
await session.commit()
await session.refresh(item)
return item