Inscription en deux temps, emails de confirmation et export Excel des bénévoles
ci-cd / build-backend (push) Successful in 30s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

Formulaire public — paiement en ligne :
- Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi
  n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la
  confirmation, webhook ou vérification en direct).
- Type de paiement « mécénat » : étape d'adresse postale avant le paiement,
  conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur.
- Page de confirmation au retour de SumUp, avec relance possible après
  échec/expiration sans ressaisir le formulaire.
- « Je paierai sur place » remplace « je ne participe pas » ; tous les
  champs activés par la coordination sont désormais obligatoires.
- La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le
  résultat réel du paiement (libellé, montant, statut).

Emails :
- Email de confirmation d'inscription à la création du bénévole (tient lieu
  de reçu de paiement quand une cotisation est payée en ligne).
- Mise en forme HTML au thème de l'application (logo de l'édition en
  en-tête) pour tous les emails envoyés, texte brut conservé en repli.

Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en
noms lisibles, cotisation et mécénat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-30 07:41:58 +02:00
co-authored by Claude Sonnet 5
parent 68d42ee303
commit a2b748566b
34 changed files with 2318 additions and 291 deletions
+96
View File
@@ -211,6 +211,15 @@ class Volunteer(TimestampMixin, table=True):
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
)
fee_paid_at: dt.datetime | None = None
# Coordonnées collectées uniquement pour un type de paiement « mécénat »
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
# conservées pour générer un reçu fiscal (cerfa) ultérieurement. `None`
# pour un bénévole n'ayant pas payé via une option de mécénat.
mecenat_address: str | None = None
mecenat_city: str | None = None
mecenat_postal_code: str | None = None
mecenat_country: str | None = None
wished_artists: list[Artist] = Relationship(
back_populates="fans",
@@ -545,6 +554,93 @@ class FormPaymentOption(TimestampMixin, table=True):
default=0,
sa_column=Column(Integer, nullable=False, server_default="0"),
)
# Type « mécénat » : avant le paiement, le bénévole passe par une étape
# supplémentaire (adresse postale) pour permettre l'édition d'un reçu
# fiscal (cerfa) — voir `Volunteer.mecenat_*` et
# `app/api/forms.py:submit_mecenat_info`.
requires_mecenat_info: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
class PendingFormSubmission(TimestampMixin, table=True):
"""Inscription en attente de paiement en ligne (voir CLAUDE.md § 2,
`Paiement de la cotisation`). Créée à la place d'un `Volunteer` quand le
bénévole choisit un type de paiement en ligne (`FormPaymentOption`) sur
le formulaire public — le `Volunteer` n'est créé qu'à la confirmation du
paiement (`app/api/forms.py:_promote_pending`), pour ne jamais compter en
base quelqu'un qui a rempli le formulaire sans aller au bout du
paiement.
Reprend les champs du formulaire (identiques à `Volunteer`, en plus
volatil) ; `position_wish_ids_json`/`artist_wish_ids_json` : listes
JSON stringifiées (pas d'association SQL pour une ligne éphémère), leurs
UUID sont résolus (silencieusement filtrés si un poste/artiste a
entretemps disparu — le paiement ne doit jamais échouer pour ça) au
moment de la promotion en `Volunteer`.
`id` sert aussi de jeton public (`GET .../form/public/pending/{id}`,
aucune authentification) : un UUID v4 est déjà pratiquement impossible
à deviner, comme un identifiant de session de paiement chez la plupart
des prestataires.
`payment_label`/`payment_amount`/`requires_mecenat_info` sont une copie
figée de l'option choisie (même principe que `Volunteer.fee_payment_label`)
— indépendante d'une modification de `FormPaymentOption` pendant que le
paiement est en cours. `checkout_id`/`checkout_status` suivent le
checkout SumUp *courant* : relancés (nouvel identifiant) à chaque
nouvelle tentative après un échec/une expiration
(`app/api/forms.py:retry_pending_payment`), sans jamais perdre les
informations déjà saisies.
`promoted_volunteer_id` : posé au moment du paiement confirmé (`PAID`) —
le `Volunteer` créé. Les champs nominatifs sont alors vidés (RGPD :
la même information vit désormais uniquement sur `Volunteer`), seuls
les champs de suivi du paiement restent. Une ligne jamais promue est
purgée après un délai (voir `_prune_stale_pending`)."""
__tablename__ = "pending_form_submission"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
first_name: str | None = None
last_name: str | None = None
email: str | None = None
phone: str | None = None
comment: str | None = None
companion_wish_note: str | None = None
available: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
position_wish_ids_json: str | None = None
artist_wish_ids_json: str | None = None
photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
photo_content_type: str | None = None
payment_label: str
payment_amount: float
requires_mecenat_info: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
mecenat_address: str | None = None
mecenat_city: str | None = None
mecenat_postal_code: str | None = None
mecenat_country: str | None = None
checkout_id: str | None = Field(default=None, index=True)
checkout_status: FeePaymentStatus | None = Field(
default=None,
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
)
promoted_volunteer_id: uuid.UUID | None = Field(
default=None, foreign_key="volunteer.id", ondelete="SET NULL"
)
class FormField(SQLModel, table=True):