From 5f80f167c8983264e0abe3ad17b558217470079d Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 23 Sep 2026 21:10:38 +0200 Subject: [PATCH] Formulaire public : encart de paiement de la cotisation via Yapla MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormTemplate.yapla_url (lien vers la page de cotisation Yapla, doit commencer par http(s)://, PATCH .../form comme description/is_open) fait apparaître un encart « Payer ma cotisation » sous la case WILLING_TO_PAY_FEE, sur le formulaire public. Configurable depuis le panneau « Personnalisation » de l'écran /inscriptions. La case reste une simple déclaration : rien ne vérifie que le paiement a eu lieu. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 23 +++++++---- backend/app/api/forms.py | 10 ++++- backend/app/api/schemas.py | 5 ++- backend/app/i18n/locales/fr.json | 1 + .../versions/0015_form_yapla_url.py | 28 ++++++++++++++ backend/app/models/domain.py | 12 ++++-- backend/tests/test_forms_api.py | 22 +++++++++++ frontend/scripts/smoke.tsx | 21 ++++++++-- frontend/src/api/mock/dataset.ts | 5 ++- frontend/src/api/mock/mock-client.ts | 8 ++++ frontend/src/api/schema.ts | 3 ++ frontend/src/i18n/locales/fr.json | 6 +++ frontend/src/pages/FormBuilderPage.tsx | 38 ++++++++++++++++++- frontend/src/pages/PublicSignupPage.tsx | 16 ++++++++ 14 files changed, 181 insertions(+), 17 deletions(-) create mode 100644 backend/app/migrations/versions/0015_form_yapla_url.py diff --git a/CLAUDE.md b/CLAUDE.md index 1242c4c..3575bfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,8 +85,11 @@ les tables sauf les tables d'association. direct vers un `VolunteerCompanionWish` ; la coordination la résout à la main depuis la fiche une fois les deux bénévoles inscrits), `self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement - informatives**, sans lien avec `Availability` ni aucun workflow de - paiement). + informatives** : rien ne vérifie qu'un paiement a réellement eu lieu, + aucun lien avec `Availability`). Si `FormTemplate.yapla_url` (ci-dessous) + est configuré, un encart « Payer ma cotisation » apparaît sous la case + `WILLING_TO_PAY_FEE`, vers la page de cotisation Yapla (externe) — la + déclaration et le paiement restent deux choses distinctes, non reliées. - **`Artist`** : programmation de l'édition (`name`, `performance_date`, `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le lendemain). Unique (`edition_id`, `name`, `performance_date`). La @@ -205,11 +208,16 @@ les tables sauf les tables d'association. d'une édition (une ligne, créée à la demande). `is_open` coupe les nouvelles soumissions sans effacer la configuration. `logo`/ `logo_content_type` (image, `PUT .../form/logo`, stockée en base comme les - autres images de l'appli) et `description` (texte libre) habillent la page - publique — aucun rapport avec les champs collectés (`FormField` - ci-dessous) : affichés au-dessus/dans le formulaire, jamais soumis par le - bénévole. `PATCH /editions/{id}/form` prend `is_open`/`description`, - chacun optionnel (absent = inchangé). + autres images de l'appli), `description` (texte libre) et `yapla_url` + habillent la page publique — aucun rapport avec les champs collectés + (`FormField` ci-dessous) : affichés au-dessus/dans le formulaire, jamais + soumis par le bénévole. `yapla_url` : lien vers la page de cotisation sur + Yapla (yapla.com, plateforme externe de paiement pour associations) — + affiché en encart sous la case `WILLING_TO_PAY_FEE` si activée (voir + `Volunteer` ci-dessus) ; doit commencer par `http://`/`https://` + (422 `form.invalid_yapla_url` sinon), aucune autre vérification (rien ne + confirme qu'un paiement a eu lieu). `PATCH /editions/{id}/form` prend + `is_open`/`description`/`yapla_url`, chacun optionnel (absent = inchangé). - **`FormField`** : un champ activé sur le formulaire (`field_type` ∈ `EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH` / `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par @@ -655,6 +663,7 @@ Aucune dépendance locale requise hors Docker. | 24 | Feuilles de poste : photo des bénévoles affectés à côté de leur nom (vignette 14 mm, case vide si absente) | à valider | | 25 | Formulaire public : photo recadrée en carré côté navigateur avant l'envoi, aperçu agrandi ; personnalisation de la page (logo, texte d'introduction) depuis l'écran de configuration (`/inscriptions`) | à valider | | 26 | Feuilles de poste : programmation du jour (horaires des artistes, tous postes confondus) sous le référent | à valider | +| 27 | Formulaire public : encart de paiement de la cotisation via Yapla (lien externe configurable), affiché sous la case correspondante | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/forms.py b/backend/app/api/forms.py index 140fa52..d0ae4df 100644 --- a/backend/app/api/forms.py +++ b/backend/app/api/forms.py @@ -102,6 +102,7 @@ def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormT fields=fields, has_logo=template.logo is not None, description=template.description, + yapla_url=template.yapla_url, ) @@ -138,7 +139,13 @@ def update_form( session: Session = Depends(get_session), ) -> FormTemplateRead: template = _get_or_create_template(session, edition.id) - for field, value in payload.model_dump(exclude_unset=True).items(): + changes = payload.model_dump(exclude_unset=True) + if changes.get("yapla_url"): + if not changes["yapla_url"].startswith(("http://", "https://")): + raise HTTPException( + status_code=422, detail={"code": "form.invalid_yapla_url"} + ) + for field, value in changes.items(): setattr(template, field, value) session.add(template) session.commit() @@ -233,6 +240,7 @@ def get_public_form( artists=artists, has_logo=template.logo is not None, description=template.description, + yapla_url=template.yapla_url, ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index bce6aa0..d987453 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -493,14 +493,16 @@ class FormTemplateRead(ApiModel): fields: list[FormFieldType] has_logo: bool description: str | None + yapla_url: str | None class FormTemplateUpdate(ApiModel): """`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet - de changer `is_open` sans toucher `description`, et inversement.""" + de changer `is_open` sans toucher `description`/`yapla_url`, etc.""" is_open: bool | None = None description: str | None = None + yapla_url: str | None = None class PublicPositionOption(ApiModel): @@ -525,6 +527,7 @@ class PublicFormConfig(ApiModel): artists: list[PublicArtistOption] has_logo: bool description: str | None + yapla_url: str | None class FormSubmissionResult(ApiModel): diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 045fb0b..5118940 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -82,6 +82,7 @@ "form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.", "form.logo_empty": "Le logo est vide.", "form.logo_too_large": "Le logo est trop volumineux.", + "form.invalid_yapla_url": "Le lien Yapla doit commencer par http:// ou https://.", "form.too_many_position_wishes": "Deux vœux de poste au maximum.", "form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.", "position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.", diff --git a/backend/app/migrations/versions/0015_form_yapla_url.py b/backend/app/migrations/versions/0015_form_yapla_url.py new file mode 100644 index 0000000..738a4d6 --- /dev/null +++ b/backend/app/migrations/versions/0015_form_yapla_url.py @@ -0,0 +1,28 @@ +"""Lien de paiement Yapla + +Revision ID: 0015_form_yapla_url +Revises: 0014_form_logo +Create Date: 2026-09-23 + +Encart affiché sur la page publique (`/inscription`), près de la case +« je participe à la cotisation » — lien vers la page de cotisation sur +Yapla (yapla.com, plateforme externe). Voir `app/api/forms.py`. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0015_form_yapla_url" +down_revision: str | None = "0014_form_logo" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("form_template", sa.Column("yapla_url", sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("form_template", "yapla_url") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 790d7c5..0f05fb1 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -467,9 +467,9 @@ class FormTemplate(TimestampMixin, table=True): """Configuration du formulaire public d'une édition. Une ligne par édition (créée à la demande). `is_open` coupe les nouvelles soumissions sans effacer la configuration (les champs activés restent enregistrés). - `logo`/`logo_content_type` et `description` habillent la page publique - (`/inscription`) — aucun rapport avec les champs collectés par - `FormField` ; stockage en base comme les autres images de l'appli + `logo`/`logo_content_type`, `description` et `yapla_url` habillent la + page publique (`/inscription`) — aucun rapport avec les champs collectés + par `FormField` ; stockage en base comme les autres images de l'appli (badges, photo de bénévole), pas de volume de fichiers à gérer.""" __tablename__ = "form_template" @@ -485,6 +485,12 @@ class FormTemplate(TimestampMixin, table=True): logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) logo_content_type: str | None = None description: str | None = None + # Lien vers la page de cotisation Yapla (yapla.com, plateforme externe de + # paiement pour associations) — encart affiché sur `/inscription` près de + # la case « je participe à la cotisation » (`FormFieldType. + # WILLING_TO_PAY_FEE`). Rien n'est vérifié côté appli : la case reste + # une déclaration, le paiement se passe entièrement chez Yapla. + yapla_url: str | None = None class FormField(SQLModel, table=True): diff --git a/backend/tests/test_forms_api.py b/backend/tests/test_forms_api.py index 4ee630e..48117f9 100644 --- a/backend/tests/test_forms_api.py +++ b/backend/tests/test_forms_api.py @@ -74,6 +74,28 @@ def test_clearing_description_with_an_explicit_null(client, edition): assert resp.json()["description"] is None +def test_no_yapla_link_by_default(client, raw_client, edition): + assert client.get(_form_url(edition)).json()["yapla_url"] is None + assert raw_client.get(_form_url(edition, "/public")).json()["yapla_url"] is None + + +def test_setting_the_yapla_link(client, raw_client, edition): + resp = client.patch( + _form_url(edition), json={"yapla_url": "https://yapla.com/exemple/cotisation"} + ) + assert resp.status_code == 200 + assert resp.json()["yapla_url"] == "https://yapla.com/exemple/cotisation" + + body = raw_client.get(_form_url(edition, "/public")).json() + assert body["yapla_url"] == "https://yapla.com/exemple/cotisation" + + +def test_rejects_a_yapla_link_without_a_scheme(client, edition): + resp = client.patch(_form_url(edition), json={"yapla_url": "yapla.com/exemple"}) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "form.invalid_yapla_url" + + def test_logo_is_coordinator_only(edition, make_account, as_account): lead = make_account(Role.LEAD, email="lead@e.fr") lead_client = as_account(lead) diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index eed4dfa..436514c 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -99,18 +99,31 @@ async function checkPublicForm() { if (config.positions.length !== positions.length) { throw new Error('formulaire public : postes manquants dans la config publique'); } - if (config.has_logo !== false || config.description !== null) { - throw new Error('formulaire public : logo/description devraient être vides par défaut'); + if (config.has_logo !== false || config.description !== null || config.yapla_url !== null) { + throw new Error('formulaire public : logo/description/lien Yapla devraient être vides par défaut'); } // Le recadrage carré (canvas) et l'upload du logo (FileReader) sont des // API navigateur — non testables sous Node (voir scripts/smoke.tsx tel - // qu'exécuté ici) ; seul le texte d'introduction est vérifiable ici. + // qu'exécuté ici) ; seul le texte d'introduction et le lien Yapla sont + // vérifiables ici. await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' }); const configWithDescription = await api.forms.getPublicConfig(e); if (configWithDescription.description !== 'Bienvenue au festival de test !') { throw new Error("formulaire public : le texte d'introduction ne revient pas correctement"); } + let invalidYaplaUrlRejectedWith = 'non'; + try { + await api.forms.setOpen(e, { yapla_url: 'yapla.com/exemple' }); + } catch (err) { + invalidYaplaUrlRejectedWith = err instanceof ApiError ? err.code : String(err); + } + await api.forms.setOpen(e, { yapla_url: 'https://www.yapla.com/fr/exemple' }); + const configWithYapla = await api.forms.getPublicConfig(e); + if (configWithYapla.yapla_url !== 'https://www.yapla.com/fr/exemple') { + throw new Error('formulaire public : le lien Yapla ne revient pas correctement'); + } + const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date); let tooManyArtistsRejectedWith = 'non'; if (sameDay.length >= 2) { @@ -157,6 +170,8 @@ async function checkPublicForm() { { formOpenByDefault: config.is_open, descriptionRoundTrips: configWithDescription.description, + invalidYaplaUrlRejectedWith, + yaplaUrlRoundTrips: configWithYapla.yapla_url, tooManyPositionsRejectedWith, tooManyArtistsRejectedWith, createdVolunteerName: `${created.first_name} ${created.last_name}`, diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index de5d832..1dd4220 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -56,6 +56,7 @@ export interface MockState { logo: string | null; // data URL logoType: string | null; description: string | null; + yaplaUrl: string | null; }; /** volunteerId -> photo (formulaire public), data URL. */ photos: Map; @@ -445,7 +446,9 @@ export function buildMockState(): MockState { backImageType: null, fields: [], }, - formTemplate: { isOpen: true, fields: [], logo: null, logoType: null, description: null }, + formTemplate: { + isOpen: true, fields: [], logo: null, logoType: null, description: null, yaplaUrl: null, + }, photos: new Map(), seq, }; diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 65b00b9..5e312e0 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -128,6 +128,7 @@ const formTemplateRead = (): FormTemplateRead => ({ fields: state.formTemplate.fields, has_logo: state.formTemplate.logo !== null, description: state.formTemplate.description, + yapla_url: state.formTemplate.yaplaUrl, }); // PDF valide minimal (une page blanche) — remplace la vraie génération @@ -1483,6 +1484,12 @@ export function createMockApi(): PlanningApi { setOpen: (_edition, body: FormTemplateUpdate) => { if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open; if (body.description !== undefined) state.formTemplate.description = body.description; + if (body.yapla_url !== undefined) { + if (body.yapla_url && !/^https?:\/\//.test(body.yapla_url)) { + throw new ApiError(422, 'form.invalid_yapla_url'); + } + state.formTemplate.yaplaUrl = body.yapla_url; + } return delay(formTemplateRead()); }, uploadLogo: async (_edition, file: File) => { @@ -1511,6 +1518,7 @@ export function createMockApi(): PlanningApi { : [], has_logo: state.formTemplate.logo !== null, description: state.formTemplate.description, + yapla_url: state.formTemplate.yaplaUrl, }); }, submitPublic: async (_edition, body: PublicFormSubmission) => { diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 5d9be91..8dff443 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -573,12 +573,14 @@ export interface FormTemplateRead { fields: FormFieldType[]; has_logo: boolean; description: string | null; + yapla_url: string | null; } /** `undefined`/absent = inchangé pour chaque champ. */ export interface FormTemplateUpdate { is_open?: boolean; description?: string | null; + yapla_url?: string | null; } export interface PublicPositionOption { @@ -600,6 +602,7 @@ export interface PublicFormConfig { artists: PublicArtistOption[]; has_logo: boolean; description: string | null; + yapla_url: string | null; } export interface FormSubmissionResult { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 3a0b009..5856e8f 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -402,12 +402,17 @@ "form.customize.description": "Texte d'introduction", "form.customize.description.placeholder": "Un mot de bienvenue affiché en haut du formulaire (facultatif)…", + "form.customize.yaplaUrl": "Lien de cotisation Yapla", + "form.customize.yaplaUrl.help": "Facultatif : lien vers votre page de cotisation sur Yapla. Un encart « Payer ma cotisation » apparaît alors sur le formulaire public, près de la case correspondante.", + "form.customize.yaplaUrl.placeholder": "https://www.yapla.com/fr/...", "publicForm.title": "Inscription bénévole", "publicForm.positionWishes.count": "{count} / {max}", "publicForm.artistWishes.none": "— Aucun —", "publicForm.field.available": "Je suis disponible et prêt·e à filer un coup de main !", "publicForm.field.willingToPayFee": "Ça ne me dérange pas de participer à la cotisation de l'association", "publicForm.photo.squareHint": "Recadrée automatiquement en carré.", + "publicForm.yapla.hint": "Le paiement se fait en ligne, via notre page Yapla.", + "publicForm.yapla.cta": "Payer ma cotisation ↗", "publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)", "publicForm.submitting": "Envoi…", "publicForm.submit": "Envoyer mon inscription", @@ -423,6 +428,7 @@ "form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.", "form.logo_empty": "Le logo est vide.", "form.logo_too_large": "Le logo est trop volumineux.", + "form.invalid_yapla_url": "Le lien Yapla doit commencer par http:// ou https://.", "form.too_many_position_wishes": "Deux vœux de poste au maximum.", "form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.", "position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.", diff --git a/frontend/src/pages/FormBuilderPage.tsx b/frontend/src/pages/FormBuilderPage.tsx index 8d84ab8..876e0c6 100644 --- a/frontend/src/pages/FormBuilderPage.tsx +++ b/frontend/src/pages/FormBuilderPage.tsx @@ -60,10 +60,11 @@ const FIELD_HELP_KEY: Record = { export function FormBuilderPage() { const template = useFormTemplate(); const setFields = useSetFormFields(); - // Deux instances indépendantes du même mutateur (même endpoint PATCH) : + // Trois instances indépendantes du même mutateur (même endpoint PATCH) : // le statut (pending/success) de l'une ne doit pas déteindre sur l'autre. const toggleOpen = useUpdateFormTemplate(); const saveDescription = useUpdateFormTemplate(); + const saveYaplaUrl = useUpdateFormTemplate(); const uploadLogo = useUploadFormLogo(); const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt); const logoInputRef = useRef(null); @@ -71,6 +72,7 @@ export function FormBuilderPage() { const [enabled, setEnabled] = useState | null>(null); const [copied, setCopied] = useState(false); const [description, setDescription] = useState(''); + const [yaplaUrl, setYaplaUrl] = useState(''); useEffect(() => { if (template.data) setEnabled(new Set(template.data.fields)); @@ -78,6 +80,9 @@ export function FormBuilderPage() { useEffect(() => { if (template.data) setDescription(template.data.description ?? ''); }, [template.data]); + useEffect(() => { + if (template.data) setYaplaUrl(template.data.yapla_url ?? ''); + }, [template.data]); if (template.isLoading || !enabled) return ; if (template.error) return ; @@ -235,6 +240,37 @@ export function FormBuilderPage() { + +
+
+ {t('form.customize.yaplaUrl')} + +
+ setYaplaUrl(e.target.value)} + placeholder={t('form.customize.yaplaUrl.placeholder')} + className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]" + /> +
+ saveYaplaUrl.mutate({ yapla_url: yaplaUrl.trim() || null })} + disabled={saveYaplaUrl.isPending} + > + {saveYaplaUrl.isPending ? t('form.saving') : t('form.save')} + + {saveYaplaUrl.isSuccess ? ( + {t('form.saved')} + ) : null} + {saveYaplaUrl.isError ? ( + + {errorMessage(saveYaplaUrl.error)} + + ) : null} +
+
); diff --git a/frontend/src/pages/PublicSignupPage.tsx b/frontend/src/pages/PublicSignupPage.tsx index d621380..226ba88 100644 --- a/frontend/src/pages/PublicSignupPage.tsx +++ b/frontend/src/pages/PublicSignupPage.tsx @@ -386,6 +386,22 @@ export function PublicSignupPage() { ) : null} + {fields.has('WILLING_TO_PAY_FEE') && config.data.yapla_url ? ( +
+ + {t('publicForm.yapla.hint')} + + + {t('publicForm.yapla.cta')} + +
+ ) : null} + {fields.has('PHOTO') ? (