É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:
co-authored by
Claude Sonnet 5
parent
cb2e84adf0
commit
bb919554ff
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Volunteer
|
||||
|
||||
_CSV = (
|
||||
"Prénom;Nom;Email;Compétences;Ven matin\n"
|
||||
"Alex;Martin;alex@ex.fr;SST, Permis B;x\n"
|
||||
"Sam;Nguyen;sam@ex.fr;SST;si besoin\n"
|
||||
).encode()
|
||||
|
||||
|
||||
def _mapping(dry_run: bool) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"dry_run": dry_run,
|
||||
"columns": {
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom",
|
||||
"email": "Email",
|
||||
"skills": "Compétences",
|
||||
},
|
||||
"availability_columns": [
|
||||
{
|
||||
"header": "Ven matin",
|
||||
"date": "2026-07-10",
|
||||
"day_part_code": "MORNING",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_analyze_returns_headers_preview_and_day_parts(client, edition):
|
||||
resp = client.post(
|
||||
f"/editions/{edition.id}/import/analyze",
|
||||
files={"file": ("benevoles.csv", _CSV, "text/csv")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["format"] == "csv"
|
||||
assert body["headers"] == ["Prénom", "Nom", "Email", "Compétences", "Ven matin"]
|
||||
assert body["row_count"] == 2
|
||||
assert body["preview"][0]["values"]["Nom"] == "Martin"
|
||||
assert body["suggested_mapping"]["first_name"] == "Prénom"
|
||||
assert {d["code"] for d in body["day_parts"]} == {
|
||||
"MORNING",
|
||||
"AFTERNOON",
|
||||
"EVENING",
|
||||
}
|
||||
assert body["edition_dates"][0] == "2026-07-09"
|
||||
|
||||
|
||||
def test_execute_dry_run_then_real(client, session, edition):
|
||||
dry = client.post(
|
||||
f"/editions/{edition.id}/import/execute",
|
||||
files={"file": ("benevoles.csv", _CSV, "text/csv")},
|
||||
data={"mapping": _mapping(dry_run=True)},
|
||||
)
|
||||
assert dry.status_code == 200
|
||||
assert dry.json()["created"] == 2
|
||||
assert session.exec(select(Volunteer)).all() == []
|
||||
|
||||
real = client.post(
|
||||
f"/editions/{edition.id}/import/execute",
|
||||
files={"file": ("benevoles.csv", _CSV, "text/csv")},
|
||||
data={"mapping": _mapping(dry_run=False)},
|
||||
)
|
||||
assert real.status_code == 200
|
||||
report = real.json()
|
||||
assert report["created"] == 2
|
||||
assert sorted(report["skills_created"]) == ["PERMIS_B", "SST"]
|
||||
assert len(session.exec(select(Volunteer)).all()) == 2
|
||||
|
||||
|
||||
def test_execute_rejects_invalid_mapping(client, edition):
|
||||
bad_mapping = json.dumps(
|
||||
{
|
||||
"dry_run": True,
|
||||
"columns": {"first_name": "Prénom", "last_name": "Nom"},
|
||||
"availability_columns": [
|
||||
{
|
||||
"header": "Ven matin",
|
||||
"date": "2026-07-10",
|
||||
"day_part_code": "NIGHT",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
resp = client.post(
|
||||
f"/editions/{edition.id}/import/execute",
|
||||
files={"file": ("benevoles.csv", _CSV, "text/csv")},
|
||||
data={"mapping": bad_mapping},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["code"] == "import.mapping.invalid"
|
||||
assert any(
|
||||
e["code"] == "import.mapping.unknown_day_part" for e in detail["errors"]
|
||||
)
|
||||
|
||||
|
||||
def test_analyze_unknown_edition_is_404(client):
|
||||
resp = client.post(
|
||||
f"/editions/{uuid.uuid4()}/import/analyze",
|
||||
files={"file": ("benevoles.csv", _CSV, "text/csv")},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_analyze_rejects_empty_file(client, edition):
|
||||
resp = client.post(
|
||||
f"/editions/{edition.id}/import/analyze",
|
||||
files={"file": ("benevoles.csv", b"", "text/csv")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"]["code"] == "import.error.empty_file"
|
||||
Reference in New Issue
Block a user