"""Export Excel de la liste des bénévoles d'une édition — voir `app/api/volunteers.py:export_volunteers`. Même bibliothèque que l'import (`openpyxl`, `app/importing/parser.py`), en écriture cette fois. Texte en dur (en-têtes de colonnes, libellés) : comme `app/sheets/pdf.py` et `app/badges/pdf.py`, un document généré n'est pas un texte d'interface — pas de `app.i18n.t()` ici, contrairement aux réponses de l'API.""" import io import uuid from openpyxl import Workbook from openpyxl.styles import Font from app.api.schemas import VolunteerRead _HEADER = [ "Prénom", "Nom", "Email", "Téléphone", "Disponible", "Postes souhaités", "Artistes souhaités", "Compagnons souhaités", "Note de compagnon", "Mode de paiement", "Cotisation choisie", "Montant (€)", "Statut du paiement", "Payée le", "Mécénat — adresse", "Mécénat — ville", "Mécénat — code postal", "Mécénat — pays", "Commentaire", "Inscrit le", ] _FEE_STATUS_LABELS = { "PENDING": "En attente", "PAID": "Payée", "FAILED": "Échouée", "EXPIRED": "Expirée", } # Caractères qui font d'une cellule Excel/LibreOffice une formule plutôt # qu'un texte littéral — injection de formule (CWE-1236) : la plupart de ces # champs viennent du formulaire public, sans authentification (nom, email, # téléphone, commentaire, note de compagnon, adresse de mécénat…). Un # préfixe `'` force l'interprétation en texte, sans changer la valeur # affichée. _FORMULA_PREFIXES = ("=", "+", "-", "@") def _safe_text(value: str) -> str: return f"'{value}" if value.startswith(_FORMULA_PREFIXES) else value def _payment_mode(v: VolunteerRead) -> str: """En ligne (un checkout SumUp a été créé), sur place (« je participe à la cotisation » sans paiement en ligne), ou rien — même règle que `frontend/src/lib/fee.ts:feeMode`.""" if v.fee_payment_status is not None: return "En ligne" if v.willing_to_pay_fee: return "Sur place" return "" def _yes_no(value: bool) -> str: return "Oui" if value else "Non" def _join_names(ids: list[uuid.UUID], names: dict[uuid.UUID, str]) -> str: return _safe_text(", ".join(names[i] for i in ids if i in names)) def _fmt_dt(value) -> str: return value.strftime("%d/%m/%Y %H:%M") if value else "" def generate_volunteers_xlsx( volunteers: list[VolunteerRead], *, position_names: dict[uuid.UUID, str], artist_names: dict[uuid.UUID, str], volunteer_names: dict[uuid.UUID, str], ) -> bytes: """`position_names`/`artist_names`/`volunteer_names` : cartes id → nom construites une fois par l'appelant (pas de requête par bénévole) — voir `app/api/volunteers.py:export_volunteers`.""" workbook = Workbook() sheet = workbook.active sheet.title = "Bénévoles" sheet.append(_HEADER) for cell in sheet[1]: cell.font = Font(bold=True) for v in volunteers: sheet.append( [ _safe_text(v.first_name), _safe_text(v.last_name), _safe_text(v.email or ""), _safe_text(v.phone or ""), _yes_no(v.self_declared_available), _join_names(v.wished_position_ids, position_names), _join_names(v.artist_ids, artist_names), _join_names(v.wished_companion_ids, volunteer_names), _safe_text(v.companion_wish_note or ""), _payment_mode(v), _safe_text(v.fee_payment_label or ""), v.fee_amount if v.fee_amount is not None else "", _FEE_STATUS_LABELS.get(v.fee_payment_status, ""), _fmt_dt(v.fee_paid_at), _safe_text(v.mecenat_address or ""), _safe_text(v.mecenat_city or ""), _safe_text(v.mecenat_postal_code or ""), _safe_text(v.mecenat_country or ""), _safe_text(v.comment or ""), _fmt_dt(v.created_at), ] ) for column_cells in sheet.columns: length = max( (len(str(c.value)) for c in column_cells if c.value is not None), default=8, ) sheet.column_dimensions[column_cells[0].column_letter].width = min( max(length + 2, 10), 40 ) buffer = io.BytesIO() workbook.save(buffer) return buffer.getvalue()