É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
+111
View File
@@ -0,0 +1,111 @@
"""Lecture d'un fichier importé (CSV ou XLSX) vers une table normalisée."""
import csv
import io
from dataclasses import dataclass
from openpyxl import load_workbook
_ENCODINGS = ("utf-8-sig", "utf-8", "cp1252", "latin-1")
_DELIMITERS = (";", ",", "\t")
_XLSX_MAGIC = b"PK\x03\x04"
class ImportParseError(Exception):
"""Fichier illisible. `code` est une clé de traduction."""
def __init__(self, code: str, **params: object) -> None:
super().__init__(code)
self.code = code
self.params = params
@dataclass
class ParsedTable:
headers: list[str]
rows: list[list[str]]
def as_dicts(self) -> list[dict[str, str]]:
return [
{h: v for h, v in zip(self.headers, row, strict=False)}
for row in self.rows
]
def parse_upload(filename: str, content: bytes) -> ParsedTable:
if not content:
raise ImportParseError("import.error.empty_file")
name = (filename or "").lower()
if content[:4] == _XLSX_MAGIC or name.endswith((".xlsx", ".xlsm")):
return _parse_xlsx(content)
return _parse_csv(content)
def _decode(content: bytes) -> str:
for enc in _ENCODINGS:
try:
return content.decode(enc)
except UnicodeDecodeError:
continue
raise ImportParseError("import.error.encoding")
def _sniff_delimiter(sample: str) -> str:
lines = [ln for ln in sample.splitlines() if ln.strip()]
header = lines[0] if lines else ""
counts = {d: header.count(d) for d in _DELIMITERS}
best = max(counts, key=lambda d: counts[d])
return best if counts[best] else ","
def _fit(row: list[str], width: int) -> list[str]:
if len(row) < width:
return row + [""] * (width - len(row))
return row[:width]
def _parse_csv(content: bytes) -> ParsedTable:
text = _decode(content)
delimiter = _sniff_delimiter(text[:4096])
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
rows = [[(cell or "").strip() for cell in row] for row in reader]
rows = [row for row in rows if any(row)]
if not rows:
raise ImportParseError("import.error.empty_file")
headers = rows[0]
width = len(headers)
return ParsedTable(headers=headers, rows=[_fit(r, width) for r in rows[1:]])
def _cell_to_str(value: object) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def _parse_xlsx(content: bytes) -> ParsedTable:
try:
workbook = load_workbook(
io.BytesIO(content), read_only=True, data_only=True
)
except Exception as exc:
raise ImportParseError("import.error.unreadable_file") from exc
worksheet = workbook.active
if worksheet is None:
workbook.close()
raise ImportParseError("import.error.empty_file")
rows = [
[_cell_to_str(cell) for cell in row]
for row in worksheet.iter_rows(values_only=True)
]
workbook.close()
rows = [row for row in rows if any(row)]
if not rows:
raise ImportParseError("import.error.empty_file")
headers = rows[0]
width = len(headers)
return ParsedTable(headers=headers, rows=[_fit(r, width) for r in rows[1:]])