Étape 3 : import CSV/Excel des bénévoles et disponibilités

- Fichier unique, processus en deux temps : POST /editions/{id}/import/analyze
  (colonnes détectées, aperçu, mapping suggéré, demi-journées de l'édition)
  puis POST /editions/{id}/import/execute (mapping validé)
- Parser CSV (délimiteur et encodage devinés, BOM) et XLSX (openpyxl),
  détection XLSX par nombre magique
- Vocabulaire des valeurs de disponibilité (tri-état, accents/casse) et des
  tailles de t-shirt ; compétences créées à la volée dans le référentiel
- Rapport ligne par ligne : codes de traduction + paramètres, jamais de
  phrase. Doublons : upsert sur email, signalement sur (nom, prénom) sans
  email, doublons intra-fichier. Mode simulation (rollback, rien de persisté)
- 40 tests : parser, valeurs, service (tous les cas), endpoints
- Dépendances : python-multipart, openpyxl

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-09 22:37:30 +02:00
co-authored by Claude Sonnet 5
parent cb2e84adf0
commit bb919554ff
19 changed files with 1443 additions and 5 deletions
+153
View File
@@ -0,0 +1,153 @@
"""Endpoints d'import : analyse puis exécution."""
import datetime as dt
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import ValidationError
from sqlmodel import Session, select
from app.db import get_session
from app.importing.parser import ImportParseError, parse_upload
from app.importing.schemas import (
AnalyzeResponse,
DayPartInfo,
ImportMapping,
ImportReport,
PreviewRow,
)
from app.importing.service import run_import, suggest_mapping, validate_mapping
from app.models import DayPart, Edition
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
_PREVIEW_ROWS = 5
_MAX_BYTES = 5 * 1024 * 1024
def _get_edition(edition_id: uuid.UUID, session: Session) -> Edition:
edition = session.get(Edition, edition_id)
if edition is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return edition
async def _read_upload(file: UploadFile) -> bytes:
content = await file.read()
if not content:
raise HTTPException(
status_code=400, detail={"code": "import.error.empty_file"}
)
if len(content) > _MAX_BYTES:
raise HTTPException(
status_code=413, detail={"code": "import.error.file_too_large"}
)
return content
def _is_xlsx(filename: str, content: bytes) -> bool:
return content[:4] == b"PK\x03\x04" or (filename or "").lower().endswith(
(".xlsx", ".xlsm")
)
def _edition_dates(edition: Edition) -> list[dt.date]:
span = (edition.end_date - edition.start_date).days
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
def _day_parts(session: Session, edition: Edition) -> list[DayPart]:
return list(
session.exec(
select(DayPart)
.where(DayPart.edition_id == edition.id)
.order_by(DayPart.order)
)
)
@router.post("/analyze", response_model=AnalyzeResponse)
async def analyze(
edition_id: uuid.UUID,
file: UploadFile = File(...),
session: Session = Depends(get_session),
) -> AnalyzeResponse:
edition = _get_edition(edition_id, session)
content = await _read_upload(file)
try:
table = parse_upload(file.filename or "", content)
except ImportParseError as exc:
raise HTTPException(
status_code=422,
detail={"code": exc.code, "params": exc.params},
) from exc
preview = [
PreviewRow(line=i + 2, values=values)
for i, values in enumerate(table.as_dicts()[:_PREVIEW_ROWS])
]
return AnalyzeResponse(
filename=file.filename or "",
format="xlsx" if _is_xlsx(file.filename or "", content) else "csv",
headers=table.headers,
row_count=len(table.rows),
preview=preview,
suggested_mapping=suggest_mapping(table.headers),
edition_dates=_edition_dates(edition),
day_parts=[
DayPartInfo(
code=dp.code,
label=dp.label,
start_time=dp.start_time,
end_time=dp.end_time,
order=dp.order,
)
for dp in _day_parts(session, edition)
],
)
@router.post("/execute", response_model=ImportReport)
async def execute(
edition_id: uuid.UUID,
file: UploadFile = File(...),
mapping: str = Form(...),
session: Session = Depends(get_session),
) -> ImportReport:
edition = _get_edition(edition_id, session)
content = await _read_upload(file)
try:
parsed_mapping = ImportMapping.model_validate_json(mapping)
except ValidationError as exc:
raise HTTPException(
status_code=422,
detail={"code": "import.mapping.invalid", "errors": exc.errors()},
) from exc
try:
table = parse_upload(file.filename or "", content)
except ImportParseError as exc:
raise HTTPException(
status_code=422,
detail={"code": exc.code, "params": exc.params},
) from exc
mapping_errors = validate_mapping(
table, parsed_mapping, edition, _day_parts(session, edition)
)
if mapping_errors:
raise HTTPException(
status_code=422,
detail={
"code": "import.mapping.invalid",
"errors": [e.model_dump() for e in mapping_errors],
},
)
return run_import(
session=session,
edition=edition,
table=table,
mapping=parsed_mapping,
)