É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,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:]])
|
||||
Reference in New Issue
Block a user