Créateur de badges refait d'après la maquette : champs sur le recto et le verso (BadgeField.face, verso placé en miroir dans le PDF pour le recto-verso), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération. Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut, migration 0027) : champ sur la fiche, autant d'exemplaires sur les planches, total affiché à la génération, colonne dans l'export Excel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
4.7 KiB
Python
146 lines
4.7 KiB
Python
"""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",
|
|
"Transaction SumUp (code)",
|
|
"Transaction SumUp (identifiant)",
|
|
"Mécénat — adresse",
|
|
"Mécénat — ville",
|
|
"Mécénat — code postal",
|
|
"Mécénat — pays",
|
|
"Commentaire",
|
|
"Texte du badge",
|
|
"Nombre de badges",
|
|
"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.fee_transaction_code or ""),
|
|
_safe_text(v.fee_transaction_id or ""),
|
|
_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 ""),
|
|
_safe_text(v.badge_text or ""),
|
|
v.badge_count,
|
|
_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()
|