From 096b7cd26a028ff61e4ba7ac1b65aec1f479fdd8 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Fri, 2 Oct 2026 07:47:17 +0200 Subject: [PATCH] =?UTF-8?q?Lien=20d'inscription=20prot=C3=A9g=C3=A9=20par?= =?UTF-8?q?=20jeton,=20re=C3=A7us=20fiscaux=20m=C3=A9c=C3=A9nat,=20infobul?= =?UTF-8?q?les=20et=20revue=20de=20s=C3=A9curit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lien d'inscription protégé par un jeton (FormTemplate.access_token, /inscription/), avec régénération depuis l'écran de configuration. - Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel. - Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée. - Infobulles (HelpTip et détail de cellule de la grille des besoins) repositionnées en position: fixed via JS, bornées à l'écran. - Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le jeton d'inscription), sujet retiré du log de remise des emails (pouvait porter un nom de bénévole), photo du formulaire public revalidée par décodage réel avant stockage, export Excel protégé contre l'injection de formule, sondage du statut de paiement limité en fréquence, pyjwt mis à jour. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 68 +++++++-- backend/app/api/auth.py | 4 +- backend/app/api/forms.py | 86 +++++++++-- backend/app/api/notifications.py | 4 +- backend/app/api/schemas.py | 4 + backend/app/api/volunteers.py | 50 ++++++ backend/app/auth/rate_limit.py | 11 ++ backend/app/email/__init__.py | 4 +- backend/app/email/branding.py | 19 ++- backend/app/email/html.py | 19 ++- backend/app/email/sender.py | 46 +++--- backend/app/exporting/xlsx.py | 36 +++-- backend/app/i18n/locales/fr.json | 3 +- backend/app/mecenat/__init__.py | 3 + .../mecenat/cerfa-mecenat-particuliers.pdf | Bin 0 -> 68752 bytes backend/app/mecenat/number_words.py | 84 ++++++++++ backend/app/mecenat/pdf.py | 132 ++++++++++++++++ .../versions/0019_form_access_token.py | 44 ++++++ backend/app/models/domain.py | 9 ++ backend/app/seed.py | 54 +++++++ backend/pyproject.toml | 7 +- backend/tests/conftest.py | 2 + backend/tests/test_api_volunteers.py | 98 +++++++++++- backend/tests/test_email.py | 29 ++++ backend/tests/test_forms_api.py | 144 ++++++++++++++++-- backend/tests/test_mecenat_number_words.py | 59 +++++++ backend/tests/test_seed.py | 20 ++- backend/uv.lock | 19 ++- frontend/nginx.conf | 9 +- frontend/scripts/smoke.tsx | 18 ++- frontend/src/api/hooks.ts | 53 +++++-- frontend/src/api/http-client.ts | 16 +- frontend/src/api/mock/dataset.ts | 3 +- frontend/src/api/mock/mock-client.ts | 21 ++- frontend/src/api/schema.ts | 3 + frontend/src/api/types.ts | 21 ++- frontend/src/app/router.tsx | 2 +- frontend/src/components/ui.tsx | 16 +- frontend/src/i18n/locales/fr.json | 9 +- frontend/src/lib/useTooltipPosition.ts | 51 +++++++ frontend/src/pages/FormBuilderPage.tsx | 26 +++- frontend/src/pages/GrillePage.tsx | 28 ++-- frontend/src/pages/PublicSignupPage.tsx | 20 ++- .../src/pages/config/ConfigVolunteers.tsx | 30 ++++ 44 files changed, 1228 insertions(+), 156 deletions(-) create mode 100644 backend/app/mecenat/__init__.py create mode 100644 backend/app/mecenat/cerfa-mecenat-particuliers.pdf create mode 100644 backend/app/mecenat/number_words.py create mode 100644 backend/app/mecenat/pdf.py create mode 100644 backend/app/migrations/versions/0019_form_access_token.py create mode 100644 backend/tests/test_mecenat_number_words.py create mode 100644 frontend/src/lib/useTooltipPosition.ts diff --git a/CLAUDE.md b/CLAUDE.md index e5db669..119fb95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,9 +203,40 @@ les tables sauf les tables d'association. sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même mécanique de téléchargement que les badges/feuilles de poste (blob → `` généré côté client, voir `BadgesPage.tsx`). +- **Reçus fiscaux mécénat** (PDF, `app/mecenat/pdf.py` + `GET + /editions/{id}/volunteers/mecenat-receipts`, COORDINATION) : un cerfa + 11580*05 officiel (`app/mecenat/cerfa-mecenat-particuliers.pdf`, PDF sans + champ AcroForm — les informations sont donc dessinées par-dessus la page 2 + du modèle, aux coordonnées mesurées sur ce document précis) par bénévole + ayant payé un type de paiement « mécénat » (repérés par + `Volunteer.fee_payment_status == PAID` et `mecenat_address` renseigné — + voir `Paiement de la cotisation` plus bas), regroupés dans un seul PDF + (2 pages par reçu, comme le cerfa). Seuls l'encadré « Donateur » (nom, + prénom, adresse), le montant (en chiffres **et en toutes lettres**, + `app/mecenat/number_words.py:amount_in_words` — conversion française pure, + testée séparément des coordonnées PDF vu le nombre d'exceptions + orthographiques : accord de « quatre-vingts »/« cents », « et onze » + irrégulier à 71) et la date du versement sont complétés ; l'encadré + « Organisme bénéficiaire » (page 1) reste vierge, à la main, + volontairement — pas de données de l'association en base à ce jour. + `pypdf` (nouvelle dépendance) fusionne l'overlay ReportLab sur la page du + modèle. Bouton « Reçus mécénat (PDF) » sur l'écran de configuration des + bénévoles, même mécanique de téléchargement que l'export Excel ; + 422 `mecenat.no_receipts` si aucun bénévole ne qualifie. - **`FormTemplate`** : configuration du formulaire public d'inscription d'une édition (une ligne, créée à la demande). `is_open` coupe les - nouvelles soumissions sans effacer la configuration. `sumup_merchant_code`/ + nouvelles soumissions sans effacer la configuration. `access_token` + (aléatoire, `secrets.token_urlsafe(16)` à la création) protège l'URL du + formulaire public : celle-ci est `/inscription/{access_token}`, jamais + `/inscription` seul — sans le jeton, impossible de deviner ou de tomber + sur le lien (`GET .../form/public`, `GET .../form/logo` et + `POST .../form/public/submit` l'exigent en plus de `edition_id`, + comparaison en temps constant via `secrets.compare_digest`, 404 générique + sinon). `POST .../form/regenerate-token` (COORDINATION) le renouvelle — + l'ancien lien cesse aussitôt de fonctionner, à repartager. Les endpoints + liés à une soumission en attente de paiement (`submit_mecenat_info`, + `get_pending_status`, `retry_pending_payment`, le webhook SumUp) ne sont + pas concernés : déjà protégés par le jeton propre à chaque soumission. `sumup_merchant_code`/ `sumup_api_key` sont les identifiants du compte marchand SumUp pour le paiement de la cotisation (`app/sumup/`) — `sumup_api_key` est un secret, jamais renvoyé par l'API (`FormTemplateRead.sumup_api_key_set: bool` à la @@ -423,9 +454,12 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`, clients mail possible). Construite par `build_message` (`app/email/sender.py`), pour **tous** les emails envoyés, sans exception. Logo de l'édition en en-tête si elle en a importé un pour son formulaire - public (`app/email/branding.py:edition_logo_url`, `FormTemplate.logo` — - lien vers `GET .../form/logo`, public) ; à défaut, un bandeau texte avec - le sujet. + public (`app/email/branding.py:edition_logo`, `FormTemplate.logo`) ; à + défaut, un bandeau texte avec le sujet. Intégré en pièce jointe « inline » + (Content-ID, `LOGO_CID`) plutôt que via une URL vers `GET .../form/logo` : + cette route exige désormais le `access_token` du formulaire public (§ 2, + `FormTemplate`) — une URL externe dans un email l'aurait exposé en clair à + quiconque lit la source du message, pas seulement à son destinataire. - **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant `PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non @@ -570,12 +604,18 @@ positionnement implicite (auto-flow) recréerait la même classe de bug. Vérifié par `scripts/smoke.tsx:checkGridLayout` (inspecte les `grid-row` rendus, pas seulement l'absence de crash — un décalage CSS n'est pas visible dans un simple rendu SSR). Hiérarchie de `z-index` volontairement large et -sans égalité (coins figés 20/21 > en-têtes jour/heure 12/11 > libellés de -poste 10 > cellules de créneau, chacune isolée dans son propre contexte -d'empilement — `relative z-0` — pour que son infobulle au survol, `z-20`, -ne rivalise jamais avec les en-têtes figés) : deux éléments sticky à égalité -de `z-index` peuvent se chevaucher de façon imprévisible pendant un -défilement selon le navigateur. Largeur adaptative par jour (`columnWidthPx`, +sans égalité entre les éléments sticky de la grille (coins figés 20/21 > +en-têtes jour/heure 12/11 > libellés de poste 10) : deux éléments sticky à +égalité de `z-index` peuvent se chevaucher de façon imprévisible pendant un +défilement selon le navigateur. L'infobulle au survol d'une cellule (détail +du créneau) est en `position: fixed`, positionnée par JS +(`lib/useTooltipPosition.ts`, réutilisé par `HelpTip`) plutôt qu'ancrée en +`absolute` sur la cellule : un ancrage `absolute` la découpait au bord de la +zone de défilement (`overflow-auto`) et la coinçait sous les en-têtes figés +quel que soit son propre `z-index` (un enfant ne dépasse jamais le contexte +d'empilement de son parent) — bug corrigé une fois (infobulle invisible, +« sous tout »), un ancrage `absolute`/`z-index` recréerait la même classe de +bug. Largeur adaptative par jour (`columnWidthPx`, en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges, beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes étroites par défaut. @@ -657,6 +697,7 @@ l'étape 21.)* │ │ ├── solver/ # suggestions OR-Tools (models, engine, loader) │ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab) │ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py) +│ │ ├── mecenat/ # reçus fiscaux cerfa (pdf.py, pypdf + ReportLab) + le cerfa officiel │ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py │ │ ├── sumup/ # client API SumUp (client.py) — paiement de la cotisation │ │ ├── bootstrap.py # instance vierge : édition + 1er compte @@ -769,12 +810,17 @@ Aucune dépendance locale requise hors Docker. | 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider | | 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider | | 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider | -| 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider | +| 21 | Formulaire public d'inscription (`/inscription/{access_token}`, sans authentification — voir étape 27) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider | | 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider | | 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider | | 24 | Corrections de stabilité de la grille des besoins (suite à l'étape 23) : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule (un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants) ; hiérarchie de `z-index` sans égalité entre les en-têtes figés et isolation de l'infobulle de survol dans son propre contexte d'empilement (ne recouvrait plus la ligne « Affectés / idéal » pendant un défilement) | à valider | | 25 | Paiement de la cotisation via l'API SumUp (remplace le lien statique Yapla) : plusieurs types de paiement configurables par édition (`FormPaymentOption` — libellé + montant, ex. « Cotisation classique », « Mécénat »), inscription en deux temps pour un paiement en ligne (`PendingFormSubmission` : le `Volunteer` n'est créé qu'à la confirmation du paiement, jamais avant), étape d'adresse postale pour un type « mécénat » (cerfa), page de confirmation en direct au retour de SumUp avec relance possible après échec, tous les champs activés désormais obligatoires, statut (`PENDING`/`PAID`/`FAILED`/`EXPIRED`) tenu à jour par webhook et visible (libellé + montant + statut) sur la fiche du bénévole, email de confirmation d'inscription à la création du bénévole (tenant lieu de reçu quand un paiement est confirmé), mise en forme HTML au thème de l'appli (logo de l'édition en en-tête) pour tous les emails envoyés | à valider | | 26 | Export Excel de la liste des bénévoles (`GET /editions/{id}/volunteers/export`, COORDINATION, `app/exporting/xlsx.py`) : coordonnées, vœux (résolus en noms lisibles), cotisation et mécénat — bouton dédié sur l'écran de configuration des bénévoles | à valider | +| 27 | Lien d'inscription protégé par jeton (`FormTemplate.access_token`, voir § 2) : l'URL du formulaire public devient `/inscription/{access_token}` (`/inscription` seul ne mène plus nulle part), bouton « Régénérer » sur l'écran de configuration (`/inscriptions`) pour révoquer un lien qui aurait fuité | à valider | +| 28 | Reçus fiscaux mécénat (`GET /editions/{id}/volunteers/mecenat-receipts`, COORDINATION, `app/mecenat/pdf.py`) : un cerfa 11580*05 officiel par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, `number_words.py`)/date complétés par-dessus le modèle (`pypdf` + ReportLab), organisme bénéficiaire volontairement laissé vierge — bouton dédié sur l'écran de configuration des bénévoles | à valider | +| 29 | Jeu de démonstration : 8 bénévoles avec une cotisation classique payée, 4 avec une cotisation mécénat payée (adresse complète) — `app/seed.py:_add_demo_payments` — pour que l'export Excel et les reçus fiscaux mécénat (étape 28) aient de quoi montrer quelque chose d'emblée | à valider | +| 30 | Infobulles (`HelpTip` et détail de cellule de la grille des besoins) repositionnées en `position: fixed` via JS (`lib/useTooltipPosition.ts`), bornées à l'intérieur de l'écran et basculant de côté si la place manque — corrige un centrage `absolute` qui les faisait déborder de l'écran près d'un bord, et une isolation `z-index` qui rendait celle de la grille invisible (coincée sous les en-têtes figés) | à valider | +| 31 | Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le `access_token` du formulaire public à tout destinataire — régression de l'étape 27, aucun test ne couvrait le rendu HTML du logo), sujet retiré du log de remise des emails (pouvait porter le nom d'un bénévole via un modèle personnalisé), image du formulaire public revalidée par décodage réel (`PIL`, pas seulement le `Content-Type` déclaré) avant stockage — évite qu'une fausse image fasse échouer la génération des badges/feuilles de poste, export Excel des bénévoles protégé contre l'injection de formule (CWE-1236, `app/exporting/xlsx.py:_safe_text`), sondage du statut de paiement en attente limité en fréquence par jeton (`pending_status_limiter`, évite un abus de l'API SumUp), `pyjwt` mis à jour (CVE listées non exploitables ici — HS256 à secret statique uniquement, jamais de JWKS — mais autant ne pas rester sur une version vulnérable) | à 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/auth.py b/backend/app/api/auth.py index 554bc22..095b346 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -29,7 +29,7 @@ from app.auth.rate_limit import login_limiter, magic_link_limiter from app.auth.tokens import create_access_token, hash_token from app.config import get_settings from app.db import get_session -from app.email import edition_logo_url, render_magic_link, send +from app.email import edition_logo, render_magic_link, send from app.models import Account, MagicLinkToken, local_now router = APIRouter(tags=["auth"]) @@ -144,7 +144,7 @@ def request_magic_link( minutes=settings.magic_link_ttl_minutes, ) background.add_task( - send, account.email, subject, body, logo_url=edition_logo_url(session, edition_id) + send, account.email, subject, body, logo=edition_logo(session, edition_id) ) if not settings.email_enabled: # Aide au dev sans SMTP : le jeton (non nominatif) est journalisé. diff --git a/backend/app/api/forms.py b/backend/app/api/forms.py index 6167dce..fa2884a 100644 --- a/backend/app/api/forms.py +++ b/backend/app/api/forms.py @@ -3,11 +3,15 @@ Deux familles d'endpoints sous le même routeur : - configuration (COORDINATION) : quels champs sont activés, formulaire ouvert ou fermé, identifiants SumUp de paiement de la cotisation ; -- publics (**aucune authentification**) : consultation de la configuration - et soumission (protégées par hCaptcha, `app/forms/captcha.py`, et un filet - de sécurité global, `app/auth/rate_limit.py:form_submission_limiter`), et - le webhook SumUp (`/public/sumup-webhook`, appelé par SumUp lui-même — pas - de hCaptcha possible serveur à serveur, voir `sumup_webhook` plus bas). +- publics (**aucune authentification**, mais — sauf le webhook — protégés + par un `token` requis en plus de `edition_id`, voir `_require_access_token` + et `FormTemplate.access_token` : `/inscription` seul, sans le jeton donné + par la coordination, ne mène nulle part) : consultation de la + configuration et soumission (protégées aussi par hCaptcha, + `app/forms/captcha.py`, et un filet de sécurité global, + `app/auth/rate_limit.py:form_submission_limiter`), et le webhook SumUp + (`/public/sumup-webhook`, appelé par SumUp lui-même — pas de hCaptcha ni + de jeton possible serveur à serveur, voir `sumup_webhook` plus bas). Nom et prénom sont toujours collectés (indispensables à la création du bénévole) : ils ne font pas partie des champs activables, contrairement aux @@ -21,8 +25,10 @@ coordination, faute de savoir si la personne visée est déjà inscrite (voir CLAUDE.md § 2, `Volunteer`).""" import datetime as dt +import io import json import logging +import secrets import uuid from fastapi import ( @@ -36,6 +42,7 @@ from fastapi import ( UploadFile, status, ) +from PIL import Image, UnidentifiedImageError from sqlalchemy import delete from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select @@ -64,11 +71,11 @@ from app.api.schemas import ( SumUpWebhookPayload, ) from app.auth.deps import require_coordinator -from app.auth.rate_limit import form_submission_limiter +from app.auth.rate_limit import form_submission_limiter, pending_status_limiter from app.config import get_settings from app.db import get_session from app.email import ( - edition_logo_url, + edition_logo, render_registration_confirmation, render_registration_paid_confirmation, send, @@ -154,9 +161,20 @@ def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormT description=template.description, sumup_merchant_code=template.sumup_merchant_code, sumup_api_key_set=bool(template.sumup_api_key), + access_token=template.access_token, ) +def _require_access_token(template: FormTemplate, token: str) -> None: + """`/inscription` seul, sans le bon jeton, ne doit rien révéler — + 404 générique (`error.not_found`), jamais un message distinguant + « mauvais jeton » de « aucun formulaire ». Comparaison à temps constant + (`secrets.compare_digest`) : un jeton de longueur correcte mais faux ne + doit pas se deviner caractère par caractère via le temps de réponse.""" + if not secrets.compare_digest(token, template.access_token): + raise HTTPException(status_code=404, detail={"code": "error.not_found"}) + + @router.get("", response_model=FormTemplateRead) def get_form( edition: Edition = Depends(get_edition), @@ -200,6 +218,23 @@ def update_form( return _template_read(template, _enabled_fields(session, edition.id)) +@router.post("/regenerate-token", response_model=FormTemplateRead) +def regenerate_access_token( + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_coordinator), + session: Session = Depends(get_session), +) -> FormTemplateRead: + """Révoque le lien d'inscription partagé jusqu'ici : un nouveau jeton + remplace l'ancien, qui cesse aussitôt de fonctionner (utile si le lien a + fuité). Rien d'autre n'est affecté — les inscriptions déjà reçues, en + attente de paiement ou en cours de paiement restent valables.""" + template = _get_or_create_template(session, edition.id) + template.access_token = secrets.token_urlsafe(16) + session.add(template) + session.commit() + return _template_read(template, _enabled_fields(session, edition.id)) + + # --------------------------------------------------------------------------- # # Types de paiement (COORDINATION) — voir `FormPaymentOption` # # --------------------------------------------------------------------------- # @@ -323,12 +358,15 @@ async def set_logo( @router.get("/logo") def get_logo( + token: str, edition: Edition = Depends(get_edition), session: Session = Depends(get_session), ) -> Response: - """Public (aucune authentification) : affiché sur `/inscription`, comme + """Public (aucune authentification, mais protégé par `token` — voir + `_require_access_token`) : affiché sur `/inscription/`, comme `GET .../form/public` — voir le commentaire du module.""" template = _get_or_create_template(session, edition.id) + _require_access_token(template, token) if template.logo is None: raise HTTPException(status_code=404, detail={"code": "error.not_found"}) return Response( @@ -342,10 +380,12 @@ def get_logo( # --------------------------------------------------------------------------- # @router.get("/public", response_model=PublicFormConfig) def get_public_form( + token: str, edition: Edition = Depends(get_edition), session: Session = Depends(get_session), ) -> PublicFormConfig: template = _get_or_create_template(session, edition.id) + _require_access_token(template, token) fields = _enabled_fields(session, edition.id) positions: list[PublicPositionOption] = [] @@ -411,7 +451,15 @@ def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None: async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None: """Validation partagée par l'inscription immédiate et l'inscription en - attente de paiement — `None` si aucun fichier fourni.""" + attente de paiement — `None` si aucun fichier fourni. `Content-Type` est + un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce + endpoint est public, sans authentification) : un fichier qui ne se + décode pas comme une vraie image malgré un `Content-Type` correct + (`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de + planter plus tard la génération des badges/feuilles de poste + (`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans + filet) — même validation que l'import d'image des badges + (`app/api/badges.py:_pixel_size`, réservé à la coordination).""" if photo is None or not photo.filename: return None content_type = photo.content_type or "" @@ -422,7 +470,16 @@ async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None: content = await photo.read() if len(content) > _MAX_PHOTO_BYTES: raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"}) - return (content, content_type) if content else None + if not content: + return None + try: + with Image.open(io.BytesIO(content)) as img: + img.verify() + except (UnidentifiedImageError, OSError) as exc: + raise HTTPException( + status_code=422, detail={"code": "form.photo_type_not_supported"} + ) from exc + return content, content_type def _filter_owned_ids( @@ -498,6 +555,7 @@ async def submit_public_form( background: BackgroundTasks, edition: Edition = Depends(get_edition), session: Session = Depends(get_session), + access_token: str = Form(...), first_name: str = Form(...), last_name: str = Form(...), hcaptcha_token: str = Form(...), @@ -517,6 +575,7 @@ async def submit_public_form( _prune_stale_pending(session, edition.id) template = _get_or_create_template(session, edition.id) + _require_access_token(template, access_token) if not template.is_open: raise HTTPException(status_code=422, detail={"code": "form.closed"}) @@ -622,7 +681,7 @@ async def submit_public_form( volunteer.email, subject, body, - logo_url=edition_logo_url(session, edition.id), + logo=edition_logo(session, edition.id), ) return FormSubmissionResult(volunteer_id=volunteer.id) @@ -682,7 +741,7 @@ def _create_pending_checkout( checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}", description=f"{pending.payment_label} — édition {edition.year}", return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook", - redirect_url=f"{base}/inscription?paiement={pending.id}", + redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}", ) if checkout is None: return None @@ -773,7 +832,7 @@ def _promote_pending( volunteer.email, subject, body, - logo_url=edition_logo_url(session, edition.id), + logo=edition_logo(session, edition.id), ) return volunteer @@ -861,6 +920,7 @@ def get_pending_status( `PublicSignupPage.tsx`) jusqu'à ce que le statut ne soit plus `PENDING` — relit toujours l'état réel auprès de SumUp à chaque appel, jamais seulement la dernière valeur connue en base.""" + pending_status_limiter.hit(str(token)) pending = _get_pending_or_404(session, edition.id, token) if pending.promoted_volunteer_id is None and pending.checkout_id: template = _get_or_create_template(session, edition.id) diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index e844f88..0cea51d 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -14,7 +14,7 @@ from app.api.schemas import ( from app.auth.deps import require_coordinator from app.auth.magic_link import build_magic_link_url, issue_magic_link_token from app.db import get_session -from app.email import edition_logo_url, render_planning, send_many +from app.email import edition_logo, render_planning, send_many from app.i18n import t from app.models import Account, Assignment, Edition, Position, Shift, Volunteer @@ -112,7 +112,7 @@ def send_planning_notification( recipients.append(volunteer.id) session.commit() # persiste les éventuels MagicLinkToken émis - background.add_task(send_many, items, logo_url=edition_logo_url(session, edition.id)) + background.add_task(send_many, items, logo=edition_logo(session, edition.id)) return PlanningNotificationResult( queued=len(items), skipped_no_email=skipped_no_email, diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index d610e24..a42018e 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -513,6 +513,10 @@ class FormTemplateRead(ApiModel): # `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est # défini, comme `has_logo` pour le logo. sumup_api_key_set: bool + # Secret nécessaire pour atteindre le formulaire public + # (`/inscription/`, voir CLAUDE.md § 2, `FormTemplate`) — + # exposé seulement ici (COORDINATION), jamais par `PublicFormConfig`. + access_token: str class FormTemplateUpdate(ApiModel): diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index e59763b..a5d7470 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -30,11 +30,13 @@ from app.auth.deps import ( ) from app.db import get_session from app.exporting import generate_volunteers_xlsx +from app.mecenat import MecenatReceipt, generate_mecenat_receipts_pdf from app.models import ( Account, Artist, Assignment, Edition, + FeePaymentStatus, Position, Role, Shift, @@ -220,6 +222,54 @@ def export_volunteers( ) +@router.get("/mecenat-receipts") +def export_mecenat_receipts( + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_coordinator), + session: Session = Depends(get_session), +) -> Response: + """Reçus fiscaux (cerfa 11580*05, `app/mecenat/pdf.py`) des bénévoles + ayant payé un type de paiement « mécénat » — repérés par + `mecenat_address` renseigné (voir CLAUDE.md § 2, Paiement de la + cotisation : ce champ n'est rempli que pour ce chemin-là). Déclaré avant + `/{volunteer_id}`, comme `/export` juste au-dessus.""" + volunteers = list( + session.exec( + select(Volunteer) + .where(Volunteer.edition_id == edition.id) + .where(Volunteer.fee_payment_status == FeePaymentStatus.PAID) + .where(Volunteer.mecenat_address.is_not(None)) + .order_by(Volunteer.last_name, Volunteer.first_name) + ) + ) + if not volunteers: + raise HTTPException( + status_code=422, detail={"code": "mecenat.no_receipts"} + ) + + receipts = [ + MecenatReceipt( + last_name=v.last_name, + first_name=v.first_name, + address=v.mecenat_address or "", + postal_code=v.mecenat_postal_code or "", + city=v.mecenat_city or "", + country=v.mecenat_country or "", + amount=v.fee_amount or 0.0, + payment_date=(v.fee_paid_at or v.created_at).date(), + ) + for v in volunteers + ] + content = generate_mecenat_receipts_pdf(receipts) + return Response( + content=content, + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="recus-mecenat-{edition.year}.pdf"' + }, + ) + + @router.get("/{volunteer_id}", response_model=VolunteerRead) def get_volunteer( volunteer_id: uuid.UUID, diff --git a/backend/app/auth/rate_limit.py b/backend/app/auth/rate_limit.py index b430560..9fe9f93 100644 --- a/backend/app/auth/rate_limit.py +++ b/backend/app/auth/rate_limit.py @@ -67,3 +67,14 @@ magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300) # n'est qu'un filet de sécurité global côté édition contre un emballement # malgré tout (30 inscriptions/5 min est très large pour un usage réel). form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300) +# Interrogation du statut d'une inscription en attente de paiement +# (`GET .../form/public/pending/{token}`) : relit toujours l'état réel +# auprès de SumUp (voir `app/api/forms.py:get_pending_status`), donc chaque +# appel coûte une requête à l'API SumUp de l'édition. Clé par jeton (pas par +# édition, contrairement à `form_submission_limiter`) : plusieurs +# inscriptions en cours en même temps ne doivent pas s'épuiser mutuellement +# leur quota de sondage. 60/5 min couvre largement le sondage légitime (la +# page de confirmation interroge toutes les 3 s, jusqu'à 40 fois — voir +# `PublicSignupPage.tsx`), tout en bornant un sondage en boucle serrée qui +# n'aurait d'autre effet que de solliciter inutilement l'API SumUp. +pending_status_limiter = RateLimiter(max_attempts=60, window_seconds=300) diff --git a/backend/app/email/__init__.py b/backend/app/email/__init__.py index 73a4e67..ffff436 100644 --- a/backend/app/email/__init__.py +++ b/backend/app/email/__init__.py @@ -4,7 +4,7 @@ (sans destinataire ni corps — RGPD). Voir CLAUDE.md. """ -from app.email.branding import edition_logo_url +from app.email.branding import edition_logo from app.email.sender import send, send_many from app.email.templates import ( render_magic_link, @@ -14,7 +14,7 @@ from app.email.templates import ( ) __all__ = [ - "edition_logo_url", + "edition_logo", "render_magic_link", "render_planning", "render_registration_confirmation", diff --git a/backend/app/email/branding.py b/backend/app/email/branding.py index a96fc78..d5578f8 100644 --- a/backend/app/email/branding.py +++ b/backend/app/email/branding.py @@ -1,20 +1,25 @@ -"""URL publique du logo d'une édition, pour l'en-tête des emails (voir -`app/email/html.py`) — `None` tant qu'aucun logo n'a été importé sur le -formulaire public (`FormTemplate.logo`, `app/api/forms.py:set_logo`).""" +"""Logo d'une édition pour l'en-tête des emails (voir `app/email/html.py`) — +`None` tant qu'aucun logo n'a été importé sur le formulaire public +(`FormTemplate.logo`, `app/api/forms.py:set_logo`). + +Renvoie les octets (intégrés en pièce jointe « inline », voir +`app/email/sender.py:build_message`), jamais une URL : `GET .../form/logo` +exige désormais le `access_token` du formulaire public +(`app/api/forms.py:_require_access_token`) — une URL externe dans un email +aurait exposé ce jeton à quiconque lit la source du message, pas seulement +la coordination.""" import uuid from sqlmodel import Session, select -from app.config import get_settings from app.models import FormTemplate -def edition_logo_url(session: Session, edition_id: uuid.UUID) -> str | None: +def edition_logo(session: Session, edition_id: uuid.UUID) -> tuple[bytes, str] | None: template = session.exec( select(FormTemplate).where(FormTemplate.edition_id == edition_id) ).first() if template is None or template.logo is None: return None - base = get_settings().public_base_url.rstrip("/") - return f"{base}/api/editions/{edition_id}/form/logo" + return template.logo, template.logo_content_type or "image/png" diff --git a/backend/app/email/html.py b/backend/app/email/html.py index bd162cb..04afe30 100644 --- a/backend/app/email/html.py +++ b/backend/app/email/html.py @@ -18,6 +18,13 @@ _YELLOW = "#FFD23F" _FONT_DISPLAY = "Arial, Helvetica, sans-serif" _FONT_MONO = "'Courier New', Courier, monospace" +# Identifiant de la pièce jointe « inline » du logo (voir +# `app/email/sender.py:build_message`) — jamais une URL externe avec le +# `access_token` du formulaire public en requête : n'importe quel +# destinataire d'un email (lien magique, confirmation d'inscription…) aurait +# pu lire ce jeton depuis la source du message, pas seulement la coordination. +LOGO_CID = "edition-logo" + def _paragraphs(body: str) -> str: """`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la @@ -30,13 +37,13 @@ def _paragraphs(body: str) -> str: ) -def render_html(*, subject: str, body: str, logo_url: str | None) -> str: - """`logo_url` : voir `app/email/branding.py:edition_logo_url` — `None` - tant que l'édition n'a pas importé de logo, l'en-tête retombe alors sur - un simple bandeau texte (le sujet, en capitales).""" - if logo_url: +def render_html(*, subject: str, body: str, has_logo: bool) -> str: + """`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant + que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un + simple bandeau texte (le sujet, en capitales).""" + if has_logo: header = ( - f'' ) diff --git a/backend/app/email/sender.py b/backend/app/email/sender.py index 7a97504..0af9086 100644 --- a/backend/app/email/sender.py +++ b/backend/app/email/sender.py @@ -12,7 +12,7 @@ from collections.abc import Iterable from email.message import EmailMessage from app.config import Settings, get_settings -from app.email.html import render_html +from app.email.html import LOGO_CID, render_html logger = logging.getLogger("app.email") @@ -34,20 +34,32 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str: def build_message( - to: str, subject: str, body: str, *, logo_url: str | None = None + to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None ) -> EmailMessage: - """`logo_url` (voir `app/email/branding.py:edition_logo_url`) : ajoute une - version HTML au thème de l'appli (`app/email/html.py`) en alternative au - texte brut — le texte reste la version de référence, envoyée telle - quelle si aucun client ne préfère le HTML.""" + """`logo` = (octets, content-type) (voir `app/email/branding.py: + edition_logo`) : ajoute une version HTML au thème de l'appli + (`app/email/html.py`) en alternative au texte brut — le texte reste la + version de référence, envoyée telle quelle si aucun client ne préfère le + HTML. Le logo, s'il y en a un, est intégré en pièce jointe « inline » + (Content-ID) référencée par la partie HTML — jamais une URL externe, qui + aurait exposé le jeton d'accès du formulaire public à quiconque lit la + source du message (voir `app/email/branding.py`).""" message = EmailMessage() message["From"] = get_settings().smtp_from message["To"] = to message["Subject"] = subject message.set_content(body) message.add_alternative( - render_html(subject=subject, body=body, logo_url=logo_url), subtype="html" + render_html(subject=subject, body=body, has_logo=logo is not None), + subtype="html", ) + if logo is not None: + content, content_type = logo + maintype, _, subtype = content_type.partition("/") + html_part = message.get_payload()[-1] + html_part.add_related( + content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>" + ) return message @@ -112,22 +124,20 @@ def deliver_many(messages: list[EmailMessage]) -> None: message["From"], _smtp_error_summary(exc), ) - # RGPD : ni destinataire ni corps, seulement le compte et le sujet. - logger.info( - "Emails remis : %d/%d — sujet=%r", - sent, - len(messages), - messages[0]["Subject"], - ) + # RGPD : ni destinataire, ni corps, ni sujet — un sujet de récapitulatif + # de planning (`app/api/notifications.py`) peut être personnalisé par la + # coordination avec le nom du bénévole (`{prenom}`/`{nom}`), ce ne serait + # alors plus un simple texte d'interface. + logger.info("Emails remis : %d/%d", sent, len(messages)) -def send(to: str, subject: str, body: str, *, logo_url: str | None = None) -> None: - deliver_many([build_message(to, subject, body, logo_url=logo_url)]) +def send(to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None) -> None: + deliver_many([build_message(to, subject, body, logo=logo)]) def send_many( - items: Iterable[tuple[str, str, str]], *, logo_url: str | None = None + items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None ) -> None: """`items` = itérable de `(destinataire, sujet, corps)` — même logo pour tous (un seul appel par édition, voir `app/api/notifications.py`).""" - deliver_many([build_message(*item, logo_url=logo_url) for item in items]) + deliver_many([build_message(*item, logo=logo) for item in items]) diff --git a/backend/app/exporting/xlsx.py b/backend/app/exporting/xlsx.py index 91e07b5..697e0ed 100644 --- a/backend/app/exporting/xlsx.py +++ b/backend/app/exporting/xlsx.py @@ -43,13 +43,25 @@ _FEE_STATUS_LABELS = { "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 _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 ", ".join(names[i] for i in ids if i in names) + return _safe_text(", ".join(names[i] for i in ids if i in names)) def _fmt_dt(value) -> str: @@ -76,24 +88,24 @@ def generate_volunteers_xlsx( for v in volunteers: sheet.append( [ - v.first_name, - v.last_name, - v.email or "", - v.phone or "", + _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), - v.companion_wish_note or "", - v.fee_payment_label or "", + _safe_text(v.companion_wish_note or ""), + _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), - v.mecenat_address or "", - v.mecenat_city or "", - v.mecenat_postal_code or "", - v.mecenat_country or "", - v.comment 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 ""), _fmt_dt(v.created_at), ] ) diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 2e38584..b162617 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -94,5 +94,6 @@ "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.", - "position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes." + "position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes.", + "mecenat.no_receipts": "Aucun bénévole n'a payé la cotisation en mécénat pour cette édition." } diff --git a/backend/app/mecenat/__init__.py b/backend/app/mecenat/__init__.py new file mode 100644 index 0000000..919f210 --- /dev/null +++ b/backend/app/mecenat/__init__.py @@ -0,0 +1,3 @@ +from app.mecenat.pdf import MecenatReceipt, generate_mecenat_receipts_pdf + +__all__ = ["MecenatReceipt", "generate_mecenat_receipts_pdf"] diff --git a/backend/app/mecenat/cerfa-mecenat-particuliers.pdf b/backend/app/mecenat/cerfa-mecenat-particuliers.pdf new file mode 100644 index 0000000000000000000000000000000000000000..791f9b4c1523884b9adfcef78c44e7715725d22f GIT binary patch literal 68752 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%0AX2Y;EqbUs#*talE z$UNi``xM)W0eNkd@F4S=0$+bpJ|t>$JE^TEAs?<(X^4K1$d{s)73bD+tsBCenoB}d zn0RRp@PML*Af@JLksy6THGoz{xNEn8gf*sQV_Wb}nKC31DVdalfASu-zEnAbqZ>uS zd37hXC+9sIq~G$D+T7qf_V~!uU7=KtO;9tFqm#v=SAr4JjTN<$>u0qs(waf&+xLXT zP?;;&Bpb{ziu0rzJmLR-^TnI1hRWhKbCWAn5@V7=1491!k)|AFcz5Sbj>;=ub%VFb z98zA~^*){!qsst;dcRiSn-S4-L(F-;c7?+1xNe)#kYCqqC+$hXg>D-zGQJKsZajec z)veeaC?*BAPN~=Eqy>Tb#^W8<$0M|^&jFjb7V1?u&lQ}%YN$zx&YRhkSn*!PYp80u{a@a#nQ=>Ge!8h(LKuqEwEVv_+n$ zGpMtI61iu~1SYm7;mQ(HL&gsgapXB@ngbRWXBtJ0R-$i`w1c*>OC-QYBcESZk=@{L zHkj=qm{njhPn2U!U&-R$c2vm%riA>whMiP5Q?ntp(;@|&rKYT4?FHE%6@dD9iiHfx z^A%3OPo{zy|5o+J zpRui(H2uOClf~TA+0G-acHf{{sBAWruUR^$Lf&g&b2`|kY^x^+t`gF0OG3N#=fu%9 ztqVWjy3mf^mr{c+^`X=LV{-s5gSw)gn^7i)9@O+R99E zTGODh>Eku1=jX_?Y69o8-xV_B-M*|aOyo+wev#&h=mMb(uWNo`l;kYt9_MS(Ug%d# zc0(S+9;j_fe`LOl4G=Tf&Dj-^cGgS{NpfC-TJ__p#5i-hl`Ef#%%Jo(EKM@j=YiRb zdxYbN!W;#0DouHpw?r4ARGbbk1fl~_otj4cc%JDMgH1BemIKjCNe(|6gY+>F@MkI| z@^@*L4mEjO5KZcz&rPUbVfz#g;BbKET`t#px>wE}`!g(|d@rwFH}(=RWUBoEG6vRI z-FZXfT*MbNgAG8kKdim%boe@{O|%Ca%lkiRN{~gb@k^u>?-E)&owMgeqnWCU;G2Hp zBHMkcp9Z=_tp=IyN|VuAHAf!F3;)DDCBRgkK_W?d49$*bYC@KtjF!!Ftu@+O+M@yq zcH4}<%Kc?)dKiH`Jy~K;g(;)aI6PWRbcnbzc~q05$76oma;(c&id%gyju11vB#Svo zR0ma%h84n!v&UX5eNfM>MN&=?QXqt_9a6ybOe?4v?d*T+N7XnClMznb?tQ zF#A-tRzuq@v$AFDK48_72g8EV@C@ZL1s=F{0J``gXUy2UzIJ@wIO)dhRjVYGZ{#qR za3-E>CWaE|2wtgik->JZmg*-)^O4=QJW1^caHq)=uW=F1;!TAhc~&VV>Dlp7zdZ^w z=AXNHy`)#il9}~$ufgJJfycGUKZ8xv!Q}j6HZho#HC+)GD<+k7~RQ{U{&;Qv6P894Pm)Zc@na=!muQGHH$^9tu|@a=?G~n%izdad+SOH zet|(p9*OxSz-)9QE}x7ta@)8B&TksBVWOz4a-2xtl{kiFm<#}hnA~J0%F@Hq&3i3; zspjiYcqK%fZ*o!{e(1xj!fh>q6)Zb@w-gSU`trNE-4K?`Ga(ld`suj}3e#&h8A$U_ zsYZkFXJWFF&gwx@tDVI>{1v^JxRa9QR1(^>51WH{3w^1*%nOnbsCWIGwt*jK0RPL0 z;q2e-`35g6w~r8h(mkH+dn87{MD%53>IO?(P+nMYhE-cuR7qz`*Vlq<{$FGQaCyET zVy{mlhP%2o%c7Ged%v$WjKZJ|)#vQ^4Y*Qkn2oZ60$SpRd%HeE#eM@RXqO7kl>#u9 ziIZJ*MKxdEY{pA2bNEO6V2uuX8!TEx+3>6?!;g-fFU?yD_Q{PdeJsxB4Pi#h8*ot= zo*K$#c8?6#iXmE9BrDEaU)DS9-myC%UQ5DIWqA^?5oThUa@ks6)Ou3a0#PidB4i*f zockM?H~L6_2XYZQNL4{v+KV7Hq>j)iNiL5`*gG zTRb;;R(3f3S|n5EZQ;_}O1^@!{bXWqU_M{oon=+)=C2?uVz=*G29j;IC7x9ZQ|ln* z8cs>#F6lk=>qsuykq-y=fY8`LAz1)2A|Ax7`u+NmFPw=+q+30jR8ye|o14;Q$GB%} z6r^M1Xl+a(9Xs7i_+^v|#pVm1gSDp0tZc#v!s(77_NQ$ejWl=a(P(EP)RwhM2Xs-} z5}oijAV&k2!W(^lWCHc3j1<|gDn@a8E^dS091Q4x_I8A*6@j`zLN13UlAk2;9qabJy zm=Cj6SVlm)5kzxdMzROvGllCWQQOymz!H-Qa!8to9IPMLiOAkcoCeF(F+mHMs#VE= z;dW;1e_GWyhAm!mqq?Z))732fYCw*_QOzTym6yM5R9rP2z3}cb<}GXbg)5T{aj{r= zKYzokT+E>0RaNEfr&V7zr_mJ-MoYS?B0op`nI@@+U2G*%` z1m$GYm<$^RqdBG)9;_i#Faa>tiM9DH`yz$ut^>Mb=xN zS(W14tJ+KY;n7=;v;~(<$$!kU$nLQ^a>7I$6&`i3TKL2;JH;MV)5PGoq20q{S;G6# zt=gfQpZZHoMM9t>mz&x-UsRdfP4wsjzIi?`a+j(YBX6HK-^6SvHEo|bCPQF3MK(FH z`qWPd#k8{TnbL-&!&8rDBvIZjRCH*CS`*To4frP3rKici4nVbGn!cUa{}KMN$Rkth zm-JGyTADs=Ub}R8no@|b8ZA!aV!KEHFJlwjkcbn!{zYo)ZcJ2yuR!}CBHn19Z85Hm z&ywVp0b~yT2C>&pq&>}RAc1ADrB-0vQqGg3#`}5nZWC(}9vrs>cgsWr2K)GYj5PA) zOQgboqT?;V&6BbiOqb4>wN|P_F%e<+s-rIc%#`hQ!cRV%B=s8cBI@pF5$7vgIAaK% z-RkV<>*zDsV1rPkZ+vD}^{{h4wF~(}0?1tGV&UOI*0+_1K*flGN}k%@4h8qJQCjy$ zmG5#{J{L$=#NPht=)t7-9d=V3j`{h+6pB5PuN7+^Y#VmN5B2O=1&gUkZ5LDu3bNc5 z{3#UIv+Ad7XpQ_8iRtI_8XoGpwK*2#V3EA1jET8F62*iY7?@Wu5%~C$Op!=KP*-4{$!7v^Zk0Yq zM$&PitfE5>iqasHvyFUkY$gjksiIRNh;JR@1fGH+$M6@8cwBhZxYWGr;=x8|ZvR3x zoy}EJipoYE*{;MCqBn=v>Zy4j`h7C#-gq9W;B2_C6~``g_@I?hWYV`_r_9#WZ9jrG zd9tX}BP*4_PB}>4NK>V<#n9S`Xp_Oa-?+4y;QJ%0!*SO$v+vigVRlJp>Z)x)0??NB z0*O%>_Ge+<42j$6#V9oRkbzm9l)E}Js5%9j%(qE)I9(F+quyk5^5f%42_l6xZAUZr z8u`-4@3+u#x!9EXk+q$jP22MoNt?Z*x`G^ngI`qmQgQnf=yU84G3t_d;Sf`|_V?Q{ z{5E7~!{S*aBM@|zihP}4-%ROVUY-+vbUm6W*Eh1P<*|S~P@WxI@-J~r9qib?nGuQ^ zd-EDDT&tD5XesEbaU<5kw{^R7QmuNkVo@%vt#5Edyv=jLgW-D>pV2kmV?qY4AtEkMm2r;XEZ`X?XR90Ie_c1_q`eBMtD4_-9g=<_F+FiY&(gV9<5>5pyxoC9eCky zjq=5&KcoNQ(2FNDCFOP$h4of_A#DJm@RC?_LVvHuMtD)96kVT=GfYXKfrFrq#2(lLQJ?hQEK^5Sa0;D_<xaqWjFbFJcN!rY)@eSj+Wf#V97c)vB20i=w{n=G=ON=2N10(pN$K2ywDb*S}Hl5_G{)wV_+w zmI*)X1}4BaMtRGSzzKMO2RgUb<)GzPE%X@3jfR=^hclK$$lz8o9ivY($aj|l?E*J* z$Y)WcI81`LYY9F#Esz4vZxs&fC^M=TbfyamOwl!Mf*GrBLIs7OeQ*S1c!7_kYj4uM zkwvZo>?)VqCivF~klT)ue6!h_1||mjHu4J@5()vBKOckR?3oyyiH15vZB|LzwFR0@ z{oc@jzKxxH#DmMdA3TXbUtD*vBZrp^4+wpHm?&LOBH)NN%1|=J9GJH$UpV0W1z=)uxIRD9_3-a44=j)PlSa3WP<++rg=;X!CJu7^>5M3QD zM&*+SYQpZ$(&Ih&B24<%>s=L1tif;Z-ll9pIpV=GL5)_S^_9_9!-}`={tm)`)ELe1kh~d6a@y zls}b851|o;mv~|Pve`oYg`Fk1&p3Ha3npf0^beeG&ClS`4ySPNm@DWf5ps{pd3F08 zkA+D+rq#@-h2w}?ejJ~ja3P>sEK{@zd|r6Pzh9+P-G9;}wrr8ogs%{O{Pr~8Cg5y5 zKH<@^VrJ_3nuA;yDuNK`Ds%Lz_H=$qI~sFIC6DOXY1~JIX|o$mEV*+!LtMyIwrCQ> zWiZzys?RLqH-l}zFR!>_+ZxvZF(B>}D3wPr(bP{csmISXdE=PG%*0u8 z7|l;HB}I5G(MHh0bk+`g7SY1Gkdl15_fL{6J6Jq$)}^@Eds|8q77A{6Ygwbdeh@N2`Im6&=HxA z8qUt-K98Sv?-cfX{V@>&vMlS)eA;)hlfNkt#K+iH>q+W=!{UdrqmBjw&@}U>}tG9z1Qj;?1*VRw*G1z zG<2HzAaH^U>Y=W*N@dn)!mY)IqG3N!fUqsChNT?0?ZG~vk}5EfF>XbGu$vK6-p9;& zL3~JwZys7Rkj5g=*z8619#3&h8NagCe#umzz)44HclgvQkbf^=E*6T`#omOQTL0uBDOc)tk@7WLrxz=9VU%(j} zB5=7{lgH*Iy)Yk+hSG@FQ9iyAD=%8!FE5(5d?10KdpG|mKUI}h{qc38LzvKo{b=+5 zcyowf++8*Jdb$V1PYZFM`brn<27GNkS17Ul8bnT<*EwNT{QzjGME6dE0&AxF`^f!B+kh zvbmkUv6|4ZKYIXnr3$I{e!1q8g+o!ZlYEnG++Eu}3sF{*$od}aS(+_T+DTtRp% zSt+87LpTxj_O=K(bh??p(jNtHkZ%Tuo>SCAM&5|(KSg}uI4DSkiju#0WKd~z5tmxP z-qgaXdWmcObI7;U#w1)*#a|cS08CIcC0AP49XPSMolQr_QSu|*?agV$^s{aQq`Tq! z_Ovgc8f^|7DRRv8A--qQXt9Jx6YKD1(2o}iE4VO(gip_|O;|?he2PY8g*i4pd*Re4 zBRN4yJ4v#YxUPbUMXEH(j8OU|Hc+hm@X~Bt(!0t`CAA@mK;S%$_UW{%hZQp9>XESK z!XaPaYte*iu!S9EWmA{t^=0zg?;~BT2!)R6HKgWzj`M>F9a6k_oG~MvMc45)7 z*(p3QQeO~jgDG7_+qm&2!jXR>1Uj7<_vRjBUvm%Zj#MZGAr|L2kc0Ndt=XrZLs8jM zhX_vbc6wy5gt@S*63--zsHe*Vvbg9T4F-Y{E21625SpWuE1j-+)z)95laUx{jVrz$$7p+@0(URU6()(QFsB)k#8s?=xv$_j5S4Z` z*?)IU#wmfhMZue&ApEg0^vohL)myJ%5@zB8=*uep&p|BNApbgbD0gFlsi9wUPgS6m zaQ}=n$S&K9PNkel>UK|KA5(Y*yy9;L?~C#`mO$EdEZcr=O$FtewkK1f$o9moy_W|BG&nNkNJU6GJ+h?Td8JBEDEYY_f4=}wY%QP;A@AvEYE;rCI~M#Q;h%sasmCPb zjVB!#=?>c})IL`fv8=HxloUk1nmg)+p+BFG96oD~nyTxjO8}rVh#&DVSTo3*v2qdq zp9ZS|0+5Sj(dYFIkm-zi)WPSKivnsLCXG;q%Jrno%8okSk^@;&k^_~XS885*VClG7 z`HM0%DkFNwIUMy8ZL3ECog9V+nxhv-sb}3FIdru2x$dg8ZUwmsM8}ABu3GPV5fPU~ z>aWBSf+$L^<>VkrC|xCl3#Et&qz})61ga21hM*M)+6Z@Y1=#E?%dsd5OTMikaGd=T zSntZ(LRhhDEQ7MrwVbF0o?*PrvBd5WWVCu3F!X99*Dy5Tw$v!zH56jSfN(LyDpys^6%-e$`zyr~2|QyVVe}ld`Hi zBubPSY(VR8Us6VZ3mO6@^u1Dr3A#ru!s1A$V>5|NAO_<(OBVnV0*+xtu_kyDMjjc& zShJXs>u~y=1fh9z?$TZgG7UYki0!%pfU8FKkrgo&4v)$EtXAlPrHG=_k{t%6Y*&-D zLohLX{ne4$)#Nok^V}vb!gekVW;7R>&%UI^o=sYct$Y8mz8;Cn)EKyZ>|58nyQ3lS z=g_xL)a$Jo2XE->b@-RH)v~=!K~B}F=}RBK=<3#YdZz%rQ)4IH2WV{+^q0*6+_%a- z+&7(9*mpmb^*`dJsY=s@#}R&0O^~d?+Q_^ltnVxg`L9eR5h@h0a*HN4AFp~1dg5v$8jb?Jg9Y7U0vK%>yCmavu30T?8?}mK9J65j6wYycW zv;DU2xz{l=`=O)jfs6JE1Pm zWhBk#_IoW*v5gSPUn8WMfz&x4yanUMMKCEHKMG1wJ4ZJV?yT;t?-CtFMf~b8 z7m;Nf-39wiY|Djrw1g|05d!NO>b#W$iTvE6dJnh@f~=pax|lWV299W@uV!Ptn79wr z%+)OZ9)dx7HATHd4&t%@`SB8A%h?C-CQ(!28y`%s0NqT2sqa3Q8N%X(Ad=|u`tF5| zM3}UR=ZM4zDbpKg1{y7A&D_k@WiCxzOzsd3VCx`fv3^(re!l3Q>13JrkO|94y`VxK z)kMi;a*l1I9k+U8%f#Z})STRGZj}_E37%s!suM|k^3?o$etOPC$!nL#>FKlm{djEf z_){}6{ycRc@pTP@<8!-FNQ9tqz!DB(XppUufskwsqCjB8aLVh&{h)q#19^s#F9oSERbz4@bl`1E)eT7L@I_HL0lA#12HkGhWT7n ztLRq>v3HDPYzae$HEFuZieb8>*r+T9zfuXj5%~oblbxj06iy>iie0Rlw|(yw-g>LL z|4iqfxL|!zt40QdG95@|lg~?+Pr~)EBf2eFPtJRTFQ&JJ7_d-qaT_Id$PvKa8iiU0B{%U@dYb z23ccH(K%$H9S|v-y+kx>`oxDHV4Y2j6B)sqf)e*DH~6KfDCfb!JE?$j@tRnHAA;dP z7h}*DOX{{4-DfuCBePa^4%wch+1rCgxMqjVE6T*A`BbLmK^AGCLnC zJa$IqPlC3Y{1RA6imRB}h0B@9F6A-lvF$tiY7w%=vyS)h8C>o!7dv|?xi-8YH1~}6 z5h)84^gvyj=wX!kh%YOnCpi^(myUNrZ6-e;=bB#-QfAub=!2v>go3&iH~xNm7hg&; z@-Jw>QQru5p1vJOaoW*~EuOp*gMpf&6L-)^UuT11Y zF9<$6m(dak2o0nKsym}lYP2q6k5~R9FJH7nriX|SEhQ5S}%c3fvIwZ0X$mnATJ;yKTtAC+I<-3Y$fTx zT;_&KH7Fz}%lF~XmK&rWUN4qvN<-fC<)64i$0b>v(7Z8>_T-Bm?)fPRo{x}BR-8|X z=wRNh|r+p z@WJMZC~n-Y@Gwx=Wp73Ct`oyF|g zC~+SsPe7B;ZU2bp$S|`jb6DS*%YWYuU20-?_1FYp!yl>XzkpybX9?M6%Q(dS;OJyN zju-VX03a5rLzSmT$;H3b$9(fV3DNVdJ|plhUTm&z zvMu&oy5f|SILtsz4Sa^hzI*cht`pPE+o8fjkcZ7q(VWjsx>O{DZHA__F%}gslYd

nCrgZMq%aR#s3+H0!`Qqp>yI$3-28`ofs*-uNLgR>=}W7O7A8WZ^TTo zL^=nmc#vq{#*~+BM2&Zc>5h3y@S+JtZmn^@;kzeY&cxps-=oM3VjMeqILOHS5C6*OG^@v176I^bVq5mERL9cKvavo$86?^H&T`ml51O#jdn_QE@NBeET}Q-&7-Wv3b{mR zy!yrc;1UC0NzPS=^SX=?zWJ+2!w4A#+t&nT|BxQRP~If2bw? zw12pl8SARAamftF0f+ko*-L+=y2c;l+m5ER{kuJ@ni&I-x0wUt10;rN*$ zv;+Qi1?=8oo z^?2Q&0?H}}JqLwV9p1c++wj6SEkx5>dxVit(ZXNCOmt3G1XlBjegSGq$eb?Pzag~i z>-Gb3Pa700zJ3uyt4i?R<$L?0{N$ypsyjglB=NDfuP0YTV;2$q5(|q16gXtoG z+RXYJxyq~yMTl1VS3JQ5@^5yPj&L$sbUX@!6AJ82wLfHOR;jF= zCfn`O{$2w9=KxxS_zGFg?H?woEi(SvhaV!Ts2=vIEDGaftNpqyD`D^zgGleC7mSMmO=E)QcY$R@r1|PEC zFF2K}4o|yZLuUa8PFqwH41&8GGW!mMsx=7VAqO?u6c9+PrR;3u+x|1xkAwhE;1@UW zVmC|3%ZMwaO!(O2AX3CLZ!DG0R11Fqf!}=-{-y?L(sTf^ym8kx2exTs^!>Ydq;XU} zJZSBkaJ+K|^r*2^7)A8js=Xxf3*1mi#qZTxcK?uTm(-8=*%&*N#$ICj3Tb!^oL@#D ze)3+}0_N3&i~JJLf}sIZLD?Abt|g!E&LvpEjw7e)@6M*T7@hK2Y}{}O@BWOwYrs9F zu)~9oR)j^ceWTZps+(hUV&=q2KtY&@#vUZ?5~%tX|W!#J~*1fZ9VTkFiW=uShWT1-?~e723v-Q!wbXDmY*jU zVQ~ULB}^9*u*~R()StciEDe6`R-tSEFGq_3#IK}+ zN6oZtBQz&%0c(g6xR>mDKj>@@9UhM zxn?)Na_z#8;4XQ}^Gn6Af*1Bw?m~P+&H_^c1y8W$j>~)Z#)q`EZi)AG>?jviH9IzG z$&7jYSyNH;R@aiwAZM$g{unG_B$+&H*dX2m7Xoge{`-PCgsd+U!DcTKY(Gy(`|INX zmwO(Os@))$aN)8TdXxQjAE34OS(3Xw(<17dccetf9AmNNQ!O#YFyrQ;9QEOoj5j!o z<&1I|EFyQ)QyB+5RVWVFdP|xo6ROIpQ+>vd4(t*x0y*m%jS84 zp;*vTSopJ` zei8xt#>^nG*?jfp(jZFjLmD%?Ryrw_uwi6!^O|TG`KU7bcN`-GH^dV9oqCCTp8prG z!FQxoCKMq_3Ri9{fwS*C+whyN#78RRwWZ(#^EUkgpA;n5JV;1sx`g)~`HHI%JD0zb ziF_L=A57bM_IdHK?S^*g?l}>J;!Y3`X@!ncip7yQrsgsA(q1v7#TV!Uz?LIaqyL0A zkV!EU(oT6lI)hqa8rrFbA!CbJzItl<_n+C%%xSTY+Stmd=g-_ANX%HYe)7#vluXtM zUvf~p!%D7k|D>H$MWOWY9%Nhi@qi3@Wbixv23w(c>1S9pHIUt%lng|qJ0*v6kss)} zc*<8F1dr1$LIBbA^6B`S$0T8}6#E?G+PLs)gO= zyRLh7!vZ(0#v-@x&1+I@kwwL9P#_KOM&&3x82$Mik<7WtSfpYg4=qn>#dl6wR zL)q-qiS^}&rlTBh9o#I3>ljZ+k?0t7w6w9`6k3&!-h$M+K zDk4f47#Nad1VkiA&PWo5C^<+NMae-#Kmmy&NDz@IAW=X;a?UUyIVxdD!)w^O_de(B zd(OS*zW2xbzB3<->8`G>TB~}kU#+UHTEkiu=du>miTC`SM+m~V(19Jek9mi zRk{kfd!?7)EQgwJmsIF?0rqlUbUAgir0g25`I1huUDQrs*2KM%x+qqBCS|-Fr+Retu=#qHzMT3w;$^?g^xx|sgN1YF9v`X z+EbKMj=phK27`18j&n!l3ocX3>=WNfuu?*asa_P$K$Q-A8BU4`{SM!ecrpQN~ z48a7g;r^WMco=cq9je92{ypnu^2AC8^*w@!U3#OdjV+E9Cpk{|({g)$c4oLz20#pS zVCyMTScG=%Bj3kSw`iZvpmOD>65-i|W9Uke;9E;hHM2uIAvWCsKq#d`ee;TAJfRH{ z(OV7FAi{#R*lSSlvQn(1>m$W8`Gi_bszlwE4gSLIkQX;=ZoON+a>7l1JQrE$FzEiH zmU8z@U~ANuH))aYF)DOd$~A`H1BJ>cpSuv$4Zmh6jktq6E}88@lk-D)p(~+H^O1T( zcVDD++$4LXLa85kPttNk=@U##p=ICAi(BEoF|PL1MdE-v`=ZPa_uVMFooWi-f8ANT z%(VhPA+i$P82Y4d-u+hW5NX{T+m7hRj!7C@{XPNRNSxCsk+BJk;-m?4Uis$*E7tZ)(8+MVCr|0%U4LAI^oO?<{yt*_QePAc6uW>fG_YcWO45RK3+{-%0 z(c92UZ-jk)gU+#5@P&b>Tu?@&rHpbzga36aruw`oo`9#P15KU99Jq{JL<`7eA8M%| zmhbSXAEsZeb}SwD&oU9M{g|MVkcJQPwlJz+yD(l{HL(MuKMBV?vpV#BR|8_{Ff_(SkS-M5Z1hMjnA)495 zNa`F*jYw;i+?&#zx+Mo~j30OSD$|SG^O|pW?-j|V8j^>yz>xy?*+nba4&48??IAI5)GFm5gVf;eDO126!el{&EOi-tv;FNN z83N}n=vpT_-{fM>Lnf+q^9A1=f0oVh`UzEVwyDzPvx8{O)~s(|PFu>qybr%U==V+0 z@`R}n{<4RrxK8+zZmZ#3%wyB%rUsc}{Zq{lX(2b^2%-XN#ttPDQ>vSNvoTIXDOQ>t z!6hG9-)6Oa!toNFYG=>196-_957u7N`jMd{r*jzOFTD7qY0Q zYCH8k@FeHm;J43xBZiZ61*B0;R1pp6)a0T<6r!oLy$k!aIhVUS(WEnH7G7B8IcI3c z6^>0dOhe!F5LQvkSUeYG3g;$HyNuCGx6rW$=wABwFsMA`ma$f|5OQ7fI3?+E?Gf+$ z5bT-t0bjz`)x|lRkQ-_$?vmD3pYF8cX*=~lMO72zcp#O(Th)x7oKLhLP^r^ho!Odu zU3su`$b5U>{~uOY950(VT3#W|r90yrKC|Q`JO{5(>;QBn^N(+=Q z3NFNtG$cX%7LEzd1LktFuy=v;9j(Ivlfl?gu1;WxB{1~OQLH6(3m1I^82$;4IF6Jf z^YdE)j?l31Z~@CX4w0mO&&13Ejy$RXEJH`d)Y`(#1&-wX?ffW!6C8;=J^{n!fVbGY zfK@ufkJo;XfJwjBha53+Bo$cs{{snra{L=RN9$A6j_Uq{o#W*x$7w(L`GXx$T(Cvq z$4%-CM<}Aro#FZ(|j784f}TL;TuCH-u5B?}i5 zbMOkBPw*F8KUv@v`dNZLLc!h~jQ(X#@<+fc5+n*m!fwFB!}B}w2?`qgL44rxPeSn< z@cklwlmXxG!~-7xBt9_jFG7O(e-STu{EJY$U^zcYK)`_S7ooslKR=599RHz~|7nZ=w8ejP;6FO>A07CQ4*W+4 z{-Xo`AL_sr!(XG6qhZcZr(TU$h~(HsSNqc_=1)$)8m}-3ukevik5JsAIqfy)OIO}i5Rlik0g3v~rgONyiQT!x`8y09wD;KyBk^~Io>f)?=6sp$l zo`e0-NKT04XPnYMhbjFP$CMYzBOosRN5s;2v^y3~9dL=;QN_+CCzv6xw=?4KaGUH5 zoHI`Hf@9nK`a#Fj(*`WD?_?h(c`dvd&&d)D3e<5FJ$K+hdy6VFc`w2+uy>l3(T^F=RkLWHR4s>|vl zC;Q;gIVWOmoV^Ch=JO_HqWOmxq-PtDb~VN1ZI1(eO6uwri`{ITT>YJ0&&1Ol@)vFE z-I1`v%JM=}Pl*F`iN=%bvLlVowU$>7#a}XxKD*EOuEEp4c`A=a5)Sv>v(qK> z+G1)LtXo<_KWdFuN8_q&RnpGvZ>ugDT|^BJ12jKowEHwuZ3J`!E*3U$Nm*S?m>m#p z739nj((EduI4hE5zHLfW{q!b_yp{Yck1G+8yRF&jev&3b&v2thJ2E|lorBgdIgwJa zT^-!XCQ+&)3BiWfl9=9KfNgY1n+u-fe5$Pc^kU^0DXXPyR@2agj>oMcEBxn~k})E< ze~AsxIV?(}1=Ct-)>+Bl7WE!@cBhE%`evE9_m}C)EKV6m^pmLbC@Cve+YgCuZS}-w zH%XplUFnX_di*|6jLg-7Z@xU{#)JD+=X%n~CQLabIBU0fz9`Ev=s8|ld;ue0Qw+fa z8#W?(=~2Z_HVxy=(WGTqUHI6gyLaav5IGmWOBelm&qG_zaQZC`QqX#p{Gpm*XO12N z(}-IU6KfJ|y5xqRB4k_4|LeJshGH^Fwijle82l4R=Bgp;d+Md^o&k7EIR7US+mCx` z0CI7=H|ViepXTb4TJp(CGi1|?5A0f~K~!d5Ro}%8Qghw8X8PXg8s9EfK6nJrAfuwV zz+2hi%@YFyJPFpQH(6!=r04I|yBBzHZmmC|dQQMeRi)ca%urzWHP?)hzF?8=gCK{l zDJjvZfljMW1e~O?<#itmWw6XUIgP_37;|DivW~j~ai@BASeLaaHD=+|_4b^CGTa0t zBFL|bpQ0HVuUxUaI-3B0Pk7$BG2sQ|$8t{KN!XZwOxSI2RI(AD}9RQa?wiX{YkD@qI-UPlg_ z{%Z0yAw)5rE4?r@s7vlOGwCg%HnsO$$`kv+<~_9)0jy+J@G6-*O)ua_*087t47i_<*CGK$qbUu-e=&_UB!{UurA##>&{xS!Z zH;tK(zg%V~){wdh2wMcJqLy)UIuv>~z&t;xC_{4G=~ImxIftv#bwA`~?OF z(>YcTW9?bia%8t%+Zd;sTl^f9r2UG?zjs4lSACOv|KKS`=W*kCtpKxooUbRvj1PV& z=9(k5q_vd7x=|EAX#2GM;{BE(DZ}WhoW1*jna9|Z?MHzY(m0we`rExm z$FTaZVDwMZCO?ssPmt$#Bt;7G{!=KuE~D{VyyriF6v@N?JCYs_9IgQ5QsB-f3&0!z zKaj&=Kn5UyLXZ9kpag^j1cXE+#6;kS^wcS05(+X33UV@X@-t`YsLxQ+Qj(KXGg8x@ zqi0}XI77w6!bHzPN6$ciR0xECkdTOwh?JO^l%A5DlKy}EIjjLFPeSM+PEZIXaDoy7 zrGy-Q0$?DIgplLU5x++t$O({7BI1*$NKS(piq8NiAW-ND0w^KKJb2Xyybcgh5}rNJ zD@8Kd9_+B&*=X66=mEv?Yj&MvNQ?jD|A{*N991O^3%JdJ)96C3yZMSNQNtBlu~ zS#Pr67Zw$ll$L!cudDym(Af0(OLJFuPj6rUz~Im%c53?D%H(2#^y;&xH6$F31V@Bf%*N2+#8pot3&xY~o0D0r}u0wRBW!0r=L4 zPi>9H)Txt%mX&|<()y8T$CCZ`1bg^zN%p&7f5-QcnEm z>;G00hz4S3n|v-#Uhingp)5Cgk?)LX&YtSNH)koF#bS5}kbRsq*336P(D_8aXAWB% z9;4aR^INEb8;uh03v563(TdzOKEv`)PUu|xhW6yv1yX4OqUb#nCunl5?;YszIz8{z zXixJN%u>wOk$2>|=+Tb)L6h>P`Sa+t1Qe#txdG3nLW*8?XK!Zzl55?wWj&h{cgIL1 zzfeC(@ba=|iVR2NG?BW$k>%FQYXf^_Yb;0wenC7#Y5iD^%HGWv#tY>95|cUJVhcrn zXmyHBVg}e+ZxzcXxO4wa;I4l-WKRp{@s6IU%kw2a0=()q{y@|fJrxRdH4A_EkpUHB zH43AO|EB3wp467wuPuco1gHU3h~)q13$k;t=X*Wx4Z6+u`xMQt<*7yG8&mlo*3Z!F zl7t9zb6@$MQf3;VK}V|gP3oTJNUe|ZRSRn3Ipd|sHM$!NoGffT=~Q2*XAm88a%jnDNMVMJpn^c+nIdFN;#`iC<7&9%VHLFeJ zSF>8I8ERy|bA2m-U*fEhWXhu-Rwe8E)iAIxu>(lkz`pkfRoaL43!J z*AD@cLjaQBEu=CL)nx2Exo(`A9;SEa>cyLrXDosn6y2biC2K_aj`o+&2*%cjk@jC~ z=n5@v`DQWf@Ysm8T!xeOQPzsbTkGe$y!XRplT-ZOix+0iZ@5$-JW>XT8Fa%rMvw6c@2o$Xd>yHtU*{9(*= z2vl7hTkMrU{X5tMoj?pP;w6?OCQzV)?4K1{T=5p?*x+{zK|lDAGNLzF-r|xPB&v1wtw*7h9LGqN5Sl9!93vYC7y>tXm#w7Au2 zGV7-5O!g+D*WCTcT75;E8&s-2-xNJ{ENvW(#a`T0^N!LMc(8iUP~NE`_0DH6N@IcU zh?!3jiwv=DB)Vr%%PTxqhgo@>C zB+(?ADQjCY`?QD32X0)Mgdv9*W5NqpuZAby;dTpD?=fK?pU^EI0+ zH`RGm3E{vpY4+ixNTSzu+Fkb-r^D=r$QZSB4R~?IPt^o3pGaSpYf z&gZ4Vn?8Cg=_Pd;G~Lt^_6yY(i7EA88J5wq$z`ta>L)t-Q3NwxIq=dw# zl6sRssnZuv)QIhp6VlO`Q5buXZSa6gIznCTloXq@pxaPm`uW)J!Wqt^kIfJ^f^PSz z7fiC=>hK3i6l_xLY(?XDGWNPvay#)RzbB+`r*6hee1#3{2JY`E90J~*dmX#2uo-Hr z?Y;g3`X9+FJ!P#QrLbsytoZW5^VB2?QshXg%!3~7{%`ZyUde6Vj_iuGiMp$r3&{!p zBUJneqmVDS!pX7VDP*QzDHY9pRQz)N)YZurk16#fQIepKVW<)IhBCK%S|H0)Yv*ayViRCcu zds~jE4GRCUb8DM5@oit|1C^RNlHQsH<$`bc2_hEQQy&pNrUd4wAx6%2E4o~8dR7G_ zb&SlLg_Es};R+3-Os{j+#-)0v5w2D*#6viebiABLS^8iijV8V67X|UuvjOsovY`FT zs`R3_L=J&IQNJI=`&Y(E4}tbHQ0p#4=BO{h7Fi^A{PEPQ0e-WceydUPy>GTCHX~vC zpfZxU*Gv4EI}%`_XODQdDH4C@5O^#+8Roww`}q(sSM$(QN%70|I0R-yK2p_h=@GCf zC!Y%!1KtKnN$Ik1bI@zkaa%_$_2vlG{coHi=qxPa5P-QF2lj_%S}`~qDV-mVp-&-i zpD3xw$K~8GRQyD{YHMK5;V8*c`Y(FH^LM~svd{xmoxA=!9Es~3WFG=nWcm{uTT$rK z(LB_UAH{*o?&~s!k4Dh~YAyEZtl3Z5p&fWS{E)l5-X}eW@H4~BZD&@_O?MCv$8#6j zy`AqE8z}R5rCQ@J38Te#NEF+DTw832V-Sda6c$4b!J^lVn^;)O42=x0tOW1Gxp<~f zq)Ax%g&7I0s|2TY6)&T5m#80X^RPpEB+x3MrQ;<0?D;O8v3C?wD4yi{+4=>uoWCn_ z>DAM;ZA~_e2!aeh#KPHCtYf#2w%%H&g}msehHNPZuB+8IR1qf_bi6t%B2$L(Vp1h; z@nt#4D?U)fs(biqUVsR+_hG~*y6nSOUOHSmm_I`$2}9z$jf=>0){Z*rI4c7xB zSv&1}g0$4qf22^9a#`@{H=JK+CIfD-)9lU1b1jef`JM}T8bD25IJPPPY1n5-iu9VA;Epse8Y@xc{4XL___uPaR`N=qd= zpV{6@q3Djph$yGO!jUv{8I&e`}~Rr^fz581W@}HjH>A?lyMy6s}cvHelH_c=wwI39z0&+=$?-ee$5+qyg}dUrJtw z*7{O+*_*5z+efJpYjt*@?KEYz=I!4suBKx~M!VueXW% z!l1xNO)&s+ZMo)k1^0t{EZJJ34I`mlDa4zmdkFONO2UYI$D;f_A)xK566fO^Fci>+ z$dwM_ug0bDQ|l*)9m-*B7{UEZ>x(t6`v#0!1NhX9M# zA+SOMG_U9HhYV#N+%Nr}Lh(g~Kn}JQB&s`b@FVpBhE=y(2Y!aoZ;llvz=T^8*?$5(g&Tbfaf95usS$f zFOFRMHXCKeB#352zR&tnlS0w%@QCGNuQ>I-rQzAW$-d!bBXw?%MM>CQ{7@SLPu}1n zJkGNE^)5y<0e=dcVqk4IvXVkk?TXkT9`);I!)rixpzD{U4pK2<;zQM6L7DsBr{k&H zuK8K!gL1U^owO-8ek{&CGPby$Taq@BD0nds@F{rh+bKc1k~G$7%e5^|JW37OD1GpD zBj?~ew+nkLI}`NrxfJrWe}r4;k6}Cs3$C)Ozu)0|3OQ_Id+A*@C7$*RmV>cRu^Mp! zC8o>{4a6xpQq-x0G&VH~Og>Qx@1l@dm4xMt(f0F?`pK1+ce2WtSk*!(z^vb=wyJMC zuAA#upJp9a*av%qA8WH)5^JTwmFG?T#8VkskVGaGARKJu*CjuSm#@D;SQX`sDb0#^+1b?*#BcZu}F@Jt2GSGH~ z%ifIFQ|4uT7e<`$puAG|^#VgBts64;eE>a)vm{K?w^7e!3lBW>RLvF%GrtdFv_vTz zEAGJwmM~ljg$hn_kK04Np_)9c7sV0uD4cfY3zMTHOr$EMnpm78Bku7mQo8W*HF9}9 z;DBQCEYxS8(^o1x4h`t0yvkU zqM#`%9E9W9b@%z!Wyj*MY-)Qmj2hW^k=X&vpbXE)fNqB>R&OUm73Bv>gZYYu4{GQ^?Dw+`M|$8%5+c~48&G4gqKvtII`_ESxh(2TD@^N>u%|5CWhzwo zjuSp~eAgYMntzOdI*tFpD6@9&RU&eLT_OhX)x)XOX(e65RXD%4RtrkCd5BcL2KL?M zDoZAlifFEU?b~6`>U|b~UE|s@=AyN>Xy))oIhU2`AvO4(1lHWo|%{!oe z7USd|<26rTUi`GmG4$Z^h!WtLzl_)u#w9n7i9~C4#)MEsH2_Xt^7X6XUWDg24E|4a9bLeVKK%2Z9TDD4AUqR9RjTQ*+d5Dm41pOZ9u1;FxQ-=hCJB2uz#@+ z&oHROQbezK)Dh~cLfL{X!_%Kw=-dIpD1Gbc!87hk{Wn#mlc?`2&K2Ll?mBSDs+vKR z!=67uf&12#?-5FXjR(h;)_QW}z9M6Y$o2(1qMPs>k*b|BFQ}6SQz))=`qJ%Nb`-TQg^OS(xpdtosJbxMC>|RZ%v4JR zoA&M7w{q+}8200bY#|~lS?;b8lygT^+z2&&0P1qvV*6kumM99V7%ngOgK|i^SzEja z(n$e$+g;b7qr0Y^3HCv)IHfd?$KvNk20SuetWIOz>c0h54UY5My1T9!*$Dnh-8tRR zLT<-_c+g&=Zw9J%b~PUmL_L4_?&1(eoEbYc(3zm2bU)Eql10HG)!TZ_ zi%h&rKlVmr2dLv;@f_%6%IOl_eLe0BOp-i5WP@VKZ|V?$;!FqXvvXCg<#DfS;2p$6 z4BZ|M*tZYFpK+frS9r1v_&<~dyqTx+2lX3$A?vI|rC`W_Qf}Srmei1N_jGTobGLQSYGxRLxPZ@<1sGhBU4*CWEWH~dhHAeu;1S+ zaaRI{_PzR{x5${66|MN@cDo+1KvkE_d_G97RQsB|%JUB5eu+Z+WbCL!y;^3(w0i~q z?Ccmu>@VE(6IUvecN_}wgpy>s%N-`H;qnF4z@+2pHCDNH+YSOd2iwRQq+H%2_}d3b zz4*QB<7(^>t+CMnEuqg_Con8ec?KL7Q+^Ov{)BIhMONxI30*IZ7KTt%c%16vkSv2Y46AoPsN!54{( zk|bQI#1~kJ=OxKK&$4n|B)C1YE=f)P;dP;{;BBh;GF8H&5bz~XFr)tnMDp~Oj>PO! zUkwOcRtf`9Lr(7TL`Ne0CgXtuNyvOFV5yWjU-;!k>f_`Q(hj1_!Bs3)ELXeDi+VUs zTwQCf$%EdbN*FjL6lC6$(Njbwse9$pySy+};@A6xI6n8a-3`So8@aW7$Su$pdUf~V zwMT7V%ykhFazHe%f^h+YZ1k)&7>19|@rH z9vp0mMt@H^MM*{@_79P^*K|Y4?bST0&=E&@Wz*8(U`na%!WJmYvJ?8eU=T# z_wmQ#y!(dhpOge16@FpZHi!r2y~Xx4aH}=q=R;N2u?kFt7`rKI$o6b~JL90oUVX`K zNn&k!l-#}h9mG1yL@GA{T-?^XgIC28kVS=89@a`-N#xyK7*U%^iDhT3$G*0W4fe9b z1^3fGs3c(-yR@(3tD-FLQxWM-QADjuGD$SHpB=^N*Om$nIbJYVlM{ai*{M4OidVyy zyd_&%^QL75NM=jGZiH^)a-*U|h%aNvRGUh1c6dvYvn2g^QGY}+?1YDa;xasnM-A+I z=?#z@F|U&>?l zlP<1;zlnjKv4XZ70%%ys@>+H6s;3Q{?iP6m1wUksuyomH%473-#f#*n##>R*T^I>D zaRTofu(RHb4sZAZ;-L51#n_>p#hydMw<=;KDx<(r>Y~VyqVbTHbmlKrmm0o%K z?nvoFfTsq;zvP2Bac+-jELVXsl?>~x5MNva;5c49+xXIbvUvZtRYY@s6A?!ABG~3s zc%;Wrhv8g7J1bX57C&U}5THOAhb?0d#898@34@<_Ry=5Ahpy_YH4af-9v=|&ag?O` z>p529+0uP#F+TU#g0IKFNixMPt?u1M9|EVnsmo>C;v>S3#m#m;6xYemel}K@9 zk?|>=M?M4B=<=dA7j0(snHtdKWiQt2jMm%xsRQ~Poi z=p^>nIXA4wO@#^uy(a2zhtr9gfx;RW8`p^uPcAXI6;6Jc;-V)$DSulMrsVq>M(sB# zvS#4ckDRf&ei9pwY~I~uTXDuu+a zlsMAiZKVuF;8`sl`U24J9P~3ce#jsYH>u<5BRzQe!wZ=c^`K4%ZW$&=O&=slAkH;* zN-~LOtjX7wzfLRN*3*#traVy#`hUxLTwE@Sl+=KX!`ZcK0H|FYTgA9&M40#mnaR&H zTz8`%3mu6*U%tAuGl{}+mxoc+A=G+qc+$7AL!TamieC|r#CnQyISLFrv(a6SC`+N} z+V@Wzq>b8e>aH6SIL7z^sbj2@(ySAIT*Yx$xv@QcT|aIEQ2elu*Uup zRKvQ8rz{^{3%EC|aIVb?x{yzVAE=^PuN2s&3(HLpe@9u_u+I*iKLk$bdfd|E>i3Tw zVGT_lrIdtG;l~|4;~x?4ypE)CoEpAwyrj%Kwl; z!Bw@kxw^ExZb6=7rB|aYz=vQ2d*{q?5^>gf*qMDnyZf&c&V4d-x^m>OBk>~=G>tJC zGHLUCGibR%8kIc2J7KMQ5*D$^-u7{_0MHNy4Cl9Rhn&BTepWbu-ut^sGVeE_Q&fob%T83Z8w% zvZkwdg<;`<&y_s`Z)jj69wvehKWQj8>OLud864f#6Jzn!X>)WeRDCi_kIt=)WyeSx=-`M5IG$dRD3(Aj&6jfH)J!v|5S9~CaoOTN9P&Xb_syd= z1b=Dm+s17pruVG2>?e!|5RJQS8F`~O!|M(pEB+_-vjPM#Vl`pcJhkng3- z`g`KmQ&6Br&mE514DGA?i(g0lHCm`m^=B)XAL5@zfzx~7!*U>}9f7`~2a#PE^`3#E|9 z&vbEwdYV{uFY4E(MV&w9-R9IH!>?ThsmS3mZzAM%f_ zJhL>SQVe(`*TM#Eba3~vgRYAGU+vDDfDw(eHp z_y%WV%f&&3VTTqT#8+F2=}&6BHq_kaV*+Q`j6dONx-0cxD`YO7$?Yh+NC0}zYYt7Y zLCSp*2T}RS`~GNZ`g_!%3A?XZtbRhkto`fDm0IgDw`^+?$$KCh+jZ3g(YFiwmWv(E z8C15tWrde|0UN7B6cgG(qK>bJ(Bkwj(%*+mKcTP&7W^z>7)NimuIldFMw?AvKbeEm zm^%sw4Pd`KlRTudwUdH8`EGVCpIkcelSW8r>Z-MdL|UL0QQ%B^e#T|I4X zwVW!y*NlO^Wj~?!Z>(v62IHb8UU<8vE4n&!=mR3bp5h7)(5 zgG#u>|96`IKynU9A^Z#H_ixu#G*_+DCTEJbyy=8>!~>-kJ~aEj78UmDAl|i>t;@bF z$tw4LK~;Ja$W0RHHt%hVjVzybJ%JICbmEgd4Za{?n;=K>ZKjau#l9hmJM;A0yVn@9 zYZ`&-kv`Rj;X(Pa{jV+IZC|9Bg@$YxPso-bsVO=Jq(X%dNo&it0Ya?trVPk zG7w&)d&Ht&@*{pow4mKUmG~vjxiPkif6p>#3~|Z!JJKf$+F*tYy+1h~Y?n8pFB@tZf)A@Wp?l@*Uw?9M4T|DEu6(>=;Fn*5ty*M7!Hs7Q^UB2ZTGV{ z=}m&DDySikC)-24l7lO?3{9;z<`PIp9|IEDxCVO5p4^QCZYoaO2|h01zJq@X<(z;M z&Cqwb(XjA?bagbun z5!DK~Z+2>~k)iJER)PHkh0TG0us z5851{_weG~T2+A=)~cvt+PzS#hQKidSoOW?VY;#{Q`ot2Up4!a{ZJ8b;85=_PJ+7= z8?W@-Wf37Knimd`;doZ-*gjWZVMca!sNr4ehUbDorgn;u@69aQw z8h>6?jLc(O*A((E8+(cgw;8YJQ8JOLo)|=z40+KMhrz`kV$LVa6qJ3SJ1hD6c4vVk z%_if)WtA9)gDLqrK~oGZe)d-U$@sO@5S0%}0T^x>GxU}*H+%FKlWu0K6C8#Ynv~q) zPsU#C0Y|%beuTq3rB8&7%J?C7|8e5#f3k83NF;f!|O0Yh#(2Ob?u0>?xnX3Leb=)tLfCOTe42}mEnRR)=+42XE6kb zRL8Y@X5!v<*Y6sLds|EMJ%|V9#M!)6iaTAh?t~Z^-2yC#Ut2-VCZ%_FEr&@PwVs>% z4`IX@{&#YdBe0es$@MG@AL9qZLhP?51 z17P7TQeb1Fq^()`(70G8npUb6a2q46Do4_U|lHZMKtp17sSmlQIq z{mbd~j7`j8HzT((pcd?{vyW=%|7g~kH|U>A0e5YYV4lV|&xj*ruP7qkZIH%Q`O z|0{E{0OwQAn|gBKnv#^0cRrSWFonk$M9N6g{vE9S_ss+P4UB9@iL&tvK9w?mt5FPh zO$B@bGQ%bPol)O&!p)4FbV`ia3z&CF4);A*U8c{7E_5l=zTLq9%(*59c?mqWEnJ|o zRYS5vd8@9X)vf6C@(S;=KZ{D*fCR6BP&Mt#V-sApTy%Xpi5+Sx5oW|3t^Tz3uDCT5 z)eweC71uiXQheFYqKwULE)Bz}ux=*#@cgAu!;l#al`^h$a#Z=7-LfU;3`V$`5*K=N zUA&CHMfmfF$Qd~3U3|cOad@>zo5b>tP2tI-Cz}m`i}6`JD|(m6pZ=-J_$^`*FIA$z zx+X&vIVB~-=Zf9n*jeMBqu_t3Qce<--WuMQCUYw0o_x8QI+PBMfri%-ls@3%unFOQ zst?kh2{%qkvJ_x(#8yCA{3@;Zs}3SqyN9jn}R+wIK(~BbB`Tr{cnO5 zsgHAg-_V9BM+Xb{!u<|HyVJG1eu4TT=87aqsdDTmw(?W;7Nt=ir7mPj$^rtj^?iaO zCf)Ibw4d(06-pu1sbaL^{SfTL#f<%--(MEfL^cn#n;v) zr!7(Co;KSaDW~{XNeAbL919);4+2Q0{3WT-<%CJm4y*!{oQjvnY8EA_FRf|Ca9k@c z_l(?508Luan0VezOKnJBKTRGn4!EWe)lal?pC&4d7$valF~yK^)OsmLF$LS%hOq7p zfH&mDVO%~+s0o(5%N7or{lz_c(mRAKST*zkc)tj9;PlzbJ?A zC|1T#e&o=$E*4IIwUrCWi~4>4fk&d@6D51-c~ZIU#D|PwwXG@{pA%f7Wz(~l2~V0` zpfx%3g41g+%rP^Ro23iNI5>V!^yAB$Ny}@UuW=29N*}_)SG&_?OX#X*OcP7ZB7Wp= z@{LKO>et13R+F3Eu1vi0B^P87zwMB?^$9(FpDaqBU0Z+{5P5Yypar-3<$;?)m)^n+ z2t`p=W(09HjjUQt;oRvf7%3MSk`%hprzhsFyT1bLuW!Eswvvq%AS1yMkprMA)aL}|zn>mgajOgP$Wca-&Lx15u+ zi;R_t6Py=u%jDM~&u_a#ob|!bB?52(VIDXVg@hvogy1M1!M`Ff7|O3bd>T%!7DpBQ zp&Cc3^!I$rDyZDPaf3t2#0hO;Z*RdR?O<#E_uTUy$5i>3-177N&aHreFdQWa5{d^d zz{3ki3WDD#0XRQO5H0`;FTe{PBS9(nk#K$<0k|-@3GwI~%ujN3-%(z0cLR8z@G%MS z9F_C8ghC1ZRu-7fFMx#efouv2^OFc6f4g6ZpAU`_5`y#bgY^jU!3FsRz_N~b0k0p` z1zrcw!4{LX2OkRB-V)AtY)gN%AlQ1me>C6m)t~Km{N#S!hd9!L-%qsB_EPrF=-%4P?jcMNwg@!B#oYnnW#I&7L)%**oGsie>=DO1FcFpr2Q##jnXBDhTMG}w zT`+bI0^Eg(u=2cT1?IVqP$5B>B9sy82nB>JLJDE#VC!IyutumN&Uj=doUry4ppYE>llwCnCqD2e7UMD z-txA3Yw7G~`3+neL0>^I|K6nwwUX&(w|nm}y?z-J98E6mCdfRNEcuS%v;F?t%TL?8 zKiFn-`Hyul@vHTSKDByg&1j=9Ws83$#xqc_TS}<)MsXc>Gh&9kNulPF99APF_QD5@ z+3vwv@6T6s&Q~3LP-pu`^F7AVzqbP=ZFyN$Ee`PESstl^Gx8sd;5b;%pKbU!^3K1s z3gqubAtcNT2kD=h=rOSWJ#Pv!8hSUS{;*Ls3rkm96A+L87G!vjqZ|E8h6IIw=jg~R zh4@f#ez4jj6Z*4>@$>P5=lpPCeiUdvyl_5Y&~*8a%=pM8K{Ex@Py!$z9ho*iFI@1* zw83+J(5(2uC`>2;eh{coZ~-s^6H1UDG&c|q!0X57JYa9e1KJ~s54-@{`ccN?0>MI$ z3OOp2542s>5!!*rf_$K@AKAYESP>5j4o3MQ;REsG=$eoaSg^3bvHc$jg92kAq4+=s zj=qJDY#qE0Bq1=akr0nCm=Da0M1uDtL8guv1#tkZ|CpiU%piaqB_16Mf3{n`i+Qhvvu-=IJZAp@d}Gl)J%NMjEIgC~eTRtPVI ziYy6;NR}Y_So{fLKn6^}iXDGL4-l+OekL4M;tX1ktpx}($1r7zu&}j7-*ZMglkf{6 zZX)i1OgNY$ln~Ot;OPd4LLP`)KLPCsg1xzk zvlR$t)(8iS-vCboA$yFM2xqhhLKA`h6T5!~M|BVmwGggUfl{mFCsC)g+SH`o`+ zOI{Brm33F1WR$$|?24&E9BE#52_5{-g#h4!`P?#N%6!M9zSqFGcei8ttv0rl?YUkm zs&k7jpITRMpZ{8>BhNrSl)aIm)_WD2I;9!Ve+|wAwYoY!9kS86^)2HK%Quw0)YY&q zt{X&+pYDXg=QfOUasmbuqr74uo82^GGhB(hM`x^?m#0aJt4)T=jm~ zoZ5%%S8eFL`tixyz@>*W(@|KT zEC6@uf?#EMa@e061Fx-L3y`M1+j!%#Lx1(!Z!XyXII=#h?k$|*B)PCB;TJEZrSIQN3`b9mas$oE4z5M`LO^21sTQjhL|q3o2jXc&)(B8 z+#-1#%Z+)~aN_+*9rwJnh>EGkvIwL#!x<9$tg^5Wj`WBK@~=XC5h2)>luP42W0#4e z{5B5r5BI8Us&?KAs;q2RwL7po8V3-2~@nDY-BvRzx7ZPt8iiR+u5-HalS3n_A< zERxMzf3e;-MZr=%`%K)*yw#Q^p0a+`@2iFSG?}hFWzk#BtaFQ@CYEINzIMEamUH6L1iIN;-=xLJ;Dl- zACnAjTon_@F=PJ9Q`)%a3tC~k$ztO_IJ@|uh{v?FEr1n zdtq;iLg!&gL2CWk?T7x`(?&bO`Ad-rDhn?XdY-9fdimWnZnCc>*{6A%rv12_Iljj( zTYS)OG5Gy(&j$07vKw6^mgPvQ(T?qR#%6ZoO(linHNG8}Ik3b72MeDGbtO(0Fxy_1 zbD|q=CDJ%-|8~H47!f4lQdbzbY(ZfbAlkTPlIeMtO)Ro*+v8J(@#gJag)Qo?H0q2b zlXlZUucVJPM&^jZ%qTB~kK%zn980Jzk^9jJsYVulO|DNqruii|6eadoefiD6}Mb&&5z=8sPFvjc+4-evv`atFbe3SLg;yZ)&4^mkZ{ zW2zS0e^a_sce*ckMcAyjnWm*8?q!wB7wp@%2-(wgMPHNqnb!8=XTI7eqMREy&}8b1 znYZ5NH6kX zyzQm`YDXsbtvvJAl{08}Mkc$%#BdZWxBYcf|!Uu}x?yOyQhyOpn( ze2Vi>LH!OPd2ZV7jkME+cpa)&r>;JqHR^5u!RDmN?pgfpfFx8PbePChxY&-lRyQ0! z#8A;fqw15Z7f!>kK^$()Ijh!r*X>QAgrD)a1yrjftCy;Oa>XiPm11E?^^J=5aKHQQ zj+s;B?)?OT4&D-Ekt6h9-TQlp#Ow)dp8H^lM6=op%3t~z^YV}ROi#h?4_#o9iP+>| zd*$(oYV?u!8~ULDTI`MicW103aiuNg+1fEViHw)FQDUDeWs@XT(KNz)=F0C?rZ<`s zse1KCxp_5;^P*N?D0V&k7@A19Gv~9J;E(wJQp(VNF>UM8!(oxV5frM&K?$EyN>n#Y z>)AJikCJ5{?+Xdkxmupu3FCCiCt>@LlLas-^Aah};(D(}S=YW=fZNzb_K?u__64~K z+wAGtWIebLThUn^ND~O|C375dr!M5>gbz{PdefLP-M6)Kt^8qx)_rh^P+YfzJK;x& zi*e2JDrwMdCw>2Vib*%_%?N8Q(cvo@7AEV{jfqy7#s!NfOp7M&^-W^mW=jUp)Mv0Y z1Zims9gMV@^Nw6nlg@%QNJ+=3i^-bLU+-PG;-+0Ao1Bb(D%Qp3qs+7WN=HjbxhQz$ zN3>FUSDIA)%i@=)%cwPZsm&-NxwL~HZdcO~7X;Ct zGv=wrw8YS(OSA78W)3ohsL3g)&rrpw$p5ZPUb3C4?Cgy4DI7snS^6`fmQ&TqhLUjz z+Z-6qXPazkA>`xB7VgB}pl(tzZ`9_r(9n{pZlPzeBG~MvWYD#+(p1{$BE>x~_Oq=s zZ{o|KfsqEDz)IIrr_w5~Kl{axLYg3DO|`CcgVn|JU91VKyYrf@RUe0VfqeUd)&`nM z0f5Kb7x{tqQVmr7O#p0>WT<3aY^eIk%Dp?zv6@q!YDk(b(T41;m<*A{DFobOxpSWG zt+;+6F%`(Mz1A7;gdTOW!l+l5}h*oGmUGy^y>2BbtW9kpKU52sxWI+!` zfCc05fPEubRC-GX5wk_v8Q7l{qV0xrC9<)ogcC8T&EqedA|qlo@!*?2HyfQqS=|dK z>!^b$1|7Mgi4TUDh4jDhd!F;>mq_;33%lbw=lvGHXu|E$bn*?Yp=&8RD2Rq5I{eaf zvrwDdsYH8|-mD_;5_*VCdE^T=sqQaaSvVRHkHFZraFw@~qY~zU>Di?0I`(^&><(^V zBeJ}MG2wG>Idm$_T{ZPrZ>ECrT3He0O_^Xk!gV@YfzV85AqLIDn4pkQqJdEy&GHy` zcAX-@X=Eey2_~7R#+Ua#I`H5MPAu<)dm_1k&^(j}IH1-c1>; z`IRCJ*{5NtlC$V^!#yDAhg((100MM(R0yGKCwM7qh|^@d#i%hZ2b_ypmQp_jeV*a! zHg8)nIR-?kli|b?u2pPVv!Y|bD6Q-YJrDmXI}&j>+Ww`o&%0rD@2TfCi-j3u!7EMqT4D|@N?SwU|UrxbVBw@JIHdEtk*P0GVRN} z!=ifUnnii&j1~5fOH@xmIAar*ro?OrfIMNr`Mk?t zfWCBlpwDQYs5k2hok;0x>OT8)n_H@fgsm1R;TmKqshiufH7sr{0kX}G=NjVh13*zKR(c0 zW{SF>4rngTECM=ImW|I90+lGw$}o$8ek;#PDbb2W0_K!erI@AQssM?~vI)ik01V}b zWN&gm_X75I*pS z^kx#N4qP%o9*D1ODisUtM**HFn@Ywqh@}Bal=CHH>BQmyVanu^M)5r~!D1kH;HYw+ zlu;b1ItUw>Tfii36eG+imH^-Z0)Wi`I#PE{(6%yy^koNNUCKcg)cU6!?{>PbiD4)a{3xd{^PvW*n zfUeRtBA^(+t1@JPyCMiCzu5q^E?J`odd_dw02#>kpzCOVccFH)naxw;2k*a_v zbU6$YwE?vOjhV3 zzvb5HqM0^ER`ME*V zMAS$M8VZ^mLz7StaM&_eNt6tziK;|0M@u|Rb%hd1j4PiY!4@IBkG88gnj>nPYXf&m z>Ztq$MhR1iatei%S4o`O1e!XNnm&OUJwZA_c-%CX+>sKE0%eJMLfVwXf-g3eI_gSR zv__OU0ZY!5oF!=5!v7}cMqCjP8EFGRm654PNf4=zXwqwZXL)aJVY$ zET_FK?2NQE{s1X=CCo>xKJtJA&XrcICHTO6xGn4;S(FoPO%S89Xx2q^0ZmWDLqjE@ zEYwHHLjz4u$eDXeJ$Iydry${otTpJsI5#|$0fC~)pW!nFBY38Qvp#hTyfecTcaAAh zF=s-=SjtZVX$GioB+IgMoJt)ZZ^$2Uyn=YePoB9hq84Zs6xt&7X~Or4x2&P+!Eq}> zw7^9;nwE^>bW5D6Ls6NMKWMrAv$YJRf23xGC|3UVUPN9KrcYO3izaMM6^1zw_sN|p zZgDyk=Lb!TvV~Z3D49_IhEKzgMsrKSkw%;G5~n`oo4ip}2D8M)up&;wmF{WrTTuB5 zH)ZNL9~{@{_f*siH7}C0cB9ah# zbQKePC9NS295xQq>p*FdMsr2j-AED|q@^pNypo;b7&*Shp%}UHG16S&QhhruOEq6^l zh_|tx+C~5~))KYgzmRRf?uB3Vi4lU4CXkfE)fhN1Ysm^@1>+}^!8@+TvUgHHNKZ0m?7s&n_oyw6S@cQ z20YlSAd35B{jWr*xIUW3auzT<>=rgc0=ra}!t~1DWu+=D@E^s?m zdZ$%rZ`vFB?y*Tul6gkdWAVOLu+C%uV?S$uYriu8GR1SOP&cy^gh7lNR)XX~23MQN zju8i~lP^*5&fHPgE@n~J?y1|l236-7ryZSj-s#>tjdpjb98NRl&wD?{EWZ8ee9RpC z11WMsxh}r0wZi$t@i6~5Z)dQ-Ql>l9ngHL9(aG9Wbt_gOr`={=aW>NM{aIVTE-6}f z1fy}jb-qO%t7%|iTc@LAaM08I_T~=KD|Z0K3+a}+JY*0l=>GJe#$wLDBi(a!zLoVm zt#?^t)lJ2L_8Bh$qONx1Vw35!v4MtGNR^XUNL7bTY*j`1TvK&zX*9%#+40#{7IewF2#y@1WObYk{3lrz+p$M~=QX zxVv-o@$_+vT3{fiFhkNCx$3h|pEVHG5;aILdrfvuRLWGFa>hkmh{RZK0-Og}fEcv+bcSyVc@k-@LnRW6Cypc? z_UECcLSwuB^;-v@v2oD@x;G0;Vc-#fMU1x`k?-P(!n!&PJ8)hlP4M=r*Az5quma69e z!?0>nzpAuXm=yr)j#;kXw!D2WZ`df(n8%@awLPC)0qXCe|FGU(YEQb$w3F$hCfm1< z;|YAV$?QL2qTNE{I96^qckjDK3{Rg^wP5C!1yxP6&E&hwCC<6%tNa>jec$GG3*FD> z^_r*aHv4tNPIw!x>Q{v!6@39ZKVp}J*C{JZ-M0`>MX0xD=eN#x8k<;n`uVo`egysU z?0aAUAqPjhUx&2Ix1qa%pN++Zw+LiZshUbsi?P*A0Pg#wr2~iF`{z)B-)*qwW zF&j@4Is=IgD(O?WKSMV9!KY<^F+sdZI66qoPxRg24GAX^AU_G%<1%4gfXQ{^8*)s3 z9tAfCXZ1VmX3GXkft!U;@%t@Ep!h+h&k15M!CrtTc8eN;xJ)SLrMEF z3oSGih)6ZaP-GFq~3t81;g5a#23Vjfkgm25rmFG$_FbE zgo{BS2MZGfH-sqng9?Qi6~s3LH}=OS#$^JB7DN`6gWE2J0Qf;fxY$2DI2fWl5I!Jr zLBBveU_L-U;6A`TpgnwiDm@@@!F`AO4vr7T56utG5BY}l{nHEL1MLOYhWExq_fNmx zZjz1YZm;gPZX!Q7zb5~62s%sffK7l; zd{TgT_Am0I@~86i?%wV;>CWpW>+bE=H$-OobOP-Ph788*PaydD04AV2up6TLwcF0` zwA;J8qr1xA%dgG9&5zfg*Kft&0Bpr?#ZSjy$1mMK-Om7=6P)wY0>lE?0{Frw4G0ae zu1}Sam7f@38o`snlEJ;ZDZ2Z+#kzyLp~_8^83&*CHQ(`%f112HmHS5BPSkuL>jMAt z!FHH#!tNRv`x7vHuTO*jPjCL)iJUr>dq*`))ZB5;^A9fXYd+&!|1|kH`09Jh|6=zB z`%;E?VfSWKrt#C63H@h%Fnpg?GJ!#bmmJZA^XSk(&7lo+8^62%;QMD*lft5E$G1t5 z-|XyDPy>TZ;TU^;JfQ=qEz8zn%A;+pEHrCvG6*^@jh-(XU3JsV7`)$iH@pHFF%Uc5 z$c=QTQ1x%lHt&&i1iXH8R2Dy6{OZlK-%=Q6IzJPiL%TmaL(4R>ZW>ny9(kFx(sqCx ztT_Yn)jV1Bui902Jbni+Z2H~NjloPe4e?=ER+kG1XabI z%mpS?M<59cU@Q4yYH=vrLdTtVo$X$>(9%|AhEDm!a5Z@q^uUN)w*t-W0@shg#|q~51mCNj`iEXFSnb^LH>n7Uk!q}*EM#Y-iOCT z5f{!raatwlUO{#3r_jsFrjdD`SD7#l%Ehg#uY1V)DSV=R*l`)MHPA(QyhKR8 zy-i1zW??qic=S&lKPeEYEl#7x9E%iYbPB77ArfJ@cx>68z9nCHY|566`sq0T1|nPX z-ugEUZ?Cd};70LYb712p+t zimNdVQ|vyzvk&7L{v$H0x@XQ#dbLf5xZh2O84_;@ZZcfB2Cd;3u||5Ptoz;Y#!}x| z6Wg{C4@aM!3Q-GZhF4BkDl)+S)UJ-C`3vKaD|Qkgq#HPawnH(^krXdCXnz!Y zMr4|cf|7|%7H9@C4;K9|atJW_30{8L1{slUyRQm9q;O(}HD6r}#XhZqjSGkQr-5ay zRv8S@M5~IcCl>8y56(r4*QsfjiG*Zjb zRAw{CW%|bj;dksKSyEi=e2jxEd1^vIXmFvIv+Vtv1)FV}0LfsQBA3(J8m(iY$Mtrk z5*%lmv207hOt=oi4dQ~CY#P7Y>@+Pz3vzxEhsLgKMa8JS8bk37LrTi;^NY2{9Dcj6 zZtI!wd#i581>v;Dbg-0l6wLf2y|@H+;XmiS&cDm3rBwDkoxwI;PKyfhVuP`Ov0>)-BPqT`9f=FB!ADk1J zk#3E(!#B(K$;@!nG|N#@FY%X+wq_)ds1N_#|?x}K*XMMb3X)x{s(j=YsN5j#5w+~4{5s;1X=8Yq@iL6d_r#h;p zVH?MzHCBxjoupYh__&lx=9Tr;*Y|spn8;8bz(dbKGz3yn&U1~H^&YKk5_gbO`$y!G zCYF~5RvH&(K2(9bRzYn}!$Y^tKs+t~ilsE!kzYHJ7i-NbFnx_bO}E=SaQqO3UAF9y&CXRW{gn>gG!s zac7Bl4n=CtuF*r4db%nt_5s@g*IpCbGil@24EW4muQ#xIPI$Eh4UyL}*7J|y;kX*A zn2eUCZmmU6m^APbvk|feJ9z4P%;U`FBO9l|i1C!o_~>Ov*DLY{KPa9)HI}a2l$6H| zhNbQi@UVp=kL`$r*f(V&k8Oy|c-Z1j$nEx`KiK0b*lYKXc6SM-;5!T{1a`Dx`;rCGzueq*LW7`n(Wk~(dWzw@K^P!SxAAhade81NB6A8>QtCFVH7#I1zLdd=FJPAofW#F0r79}S!yR~Udbyd(#~JPgvr~Ao3$!{Y2Dp4H2pyNj zQG|2F*zaC!9tw?EDa#m>oeidRE?F=|&D7@@Kg72n+6Zm~sUQS9m2X44XProl9+Gj5 zO*;NU<0eiDX{DUavO)b~wJt{jCx(z=O$`zXdd#6k{Uzcre>(LMh1W=1?nlz$_l9AZ zIEQ6*Xc3u&;n0OCG-A8dBsHJQ>+mobN&YTpMqfUX=*N*;jJch%+J+bzU|d{QPGNcM zNrXw*vJ96JU^kL9j+jbIj7vyN`?^Avo8c-%GMsq=7c(tNx!%b%dwG5jCxv3L%srRl4pRZUN zmy}ujF7tCjm3Xa-MY8YGSZ#|`k@!rwy}Qo18YeTPZu2L27*yZI5d=r{yqTibz%bSy zh#?5cJj&2bBkJ0vl>x^`p3iNxt?APAT;Gmz-CZ!R1!A+qJZJn#XuN>=qnRGAP^V>u^+;SB zUW^x)R=BpKV@Ee&92vR4-0UJaUklQnm!!AOPfGj7(tl}XUBS07jMx)B%)m{n`;xD* zpmRh^%Ux?n5w2PISx1%s2IYwS_}fFQp@ewe??IZHlRj;9ykwpLuE*kZjUc|e7=97J z`cg%bLRsGxC?YX-@R*6IrNE?xmN&VPmYEXy=!W^ncCe?+(PYGlZo2UX5t4ppN^x{# zv=V=`0tZg+9hK?w%yyR-a2zduE@_y(bxLFmZny3BWTrEY*y>_rrTxmqa zrO5A2jds`0uAhaQ@#qQeFJu-zi@J&4r=B^jVYM=90Y3aU)YwM^k%%DB@!Yc)S6rv^=9i59I?CN4z+IHw~0Ex6s#(_vb~T=>{PLc^}m05 ztA$3yWwV^j77G09#>2ZmOTw4(gQeRi_$3$Iw+{cG(aFEe--}Mc=JT|vavR>U`u_S8 z{NG&Fy|>j2o%g=qNW5gVd8vT@r}Ym|1e`2X$Xf zl;&xDQpmDH;XDZL>l|sgV6hmao2p<1$fasdx~R6^MF3gh#auqSp^?>hlym+2;}FGt zr`!4Y%lP@rSuArDQiLq!jBHw>d0K;^KQpoEX8Y0|j=^skN*9-y*549at&P6*WlHYh9ykcrI6?9hOuXjdQ8Gr2LeK!?nCfr|C$muCs-;&h;t*>>roM zGg7-WH|ozcyu6;gR$k53UkF;QpFD7xCNhV+rbHikV6kH+8Pzn$zvh|wi8Fy<_{R{M1wDnBG6B}SCq+sYa(gtxgl$wK zf;P{L>xYGD1WcXug513X&{jzR}a&12FmBHd%87E`I7W zVBp<0%peCzP%%cNe*4Vhx$_hZg2^T1X+jdAa>nI?UYicS4*@n?{7xCBN`XCg5(auJl$&)bvrvUCwQpYCsqXuKH<*)72RL)Efy}qaADE& zVzmQv$m$nVd zl!5qC(1TbZ0K#n!^s(=8VWS_eyq~r?6f!joGIvJumHT%nf+?KWNo30)je~7T;@56u zt3HjOhQ`>JNvT`6^Z9vSuWM&bMSIZ$d(Z&h3@dS-i9~n-fhwwdvr~mesNj>*sP;a7 zZ3c!K$|`u}1_Zy)2Iv=hk@g-r?CO{|B<$)1<TM+Ams^*S{d87e$oqN$cx&LNpzH_A)+HgWO%C$_&^f4+!%3L^q9S?*nOdeosh~! zif$|ya8D`Ja`9FYWmS_yyU$!JO*!=BzHs(y_~%j%kPlr&G2ojS{?OS{dT zkCUCKy}QmMjF4G=C+CI5RrC|rw+|D*))UzGs?loKtGf&PY-eZ=a+heJaa*5 zv456!oI9qd0zK*HQ6cpx4$+|o20b1gO3lRICycRL=1-jmz=4D)AfQ)wZZO94MoJvA zUrFEdB7fotR_fmUi9=QvRa<>I9fXn$5>zxIwwyw204x4VdLj^~TFhf=(T%t_P1!dK z&fk~9^yXsHPXUoIl@~01^UQ$qB)r~s-#zgu_@HpM`?WoyQF0{QeTB4!s$Y{bfV0(9 zatZU))2fO$xmAC7k7P8d!Vi@$NM;G{WWbT4SOl*%FmhJk&I2Wu1QD{O5w{)%-ZtM= zvf+ZNKfVpVoW|CQkEE><^6}ujdctpNKnnIpp#$&m@O7!^D{L81T{B1YU5xr!#WbHw zdP42tXLPGw3T&Wz4YA$xR9B!%)}Z@tZqs@e5q&9|iW}x4lpJ$(J}#M*MSK1}&2MO; zOUAN=PyGpowvX3pf+BPLyXV0Si_q=n8n?dF&Za$;U*of5X$)w@xOHDriO`f5-0z)V zD|!pURb1ijs|FULYVNRZv8ThGPXhU)CozP59+tIrfHy6zTxNG$R=ID{F%{V%;Jhko z>cSEH6*uyesUj`?>JUwQP^-)8O3R=GseTnF5itynV4=>tF{sJoGs3bi6(?f)${|00 z>Fau&a8j|k4dAU?SdyKADIn`5PEE@61E5nCEdJ8fh^)m&>s{QfIDCWAmgiYt&zCdg z90RM}qI!iWkWJzu-?IhG^{K{IYj{e6%c?$ZDqp8RQ}UO1r#y-31)qd^&YAEIcoDyD zsBM*oc!R=ybTa-O(cnN+U#&Cl6Kg;lhoM{Tr?36VbIIp`tV2wl7^-1x&jFQtkvd|~ z{i;H#`m^%yU*bZ1eeqS1J*vO%hETtN69!PZb9^Cxce1+@SGYmA5O>ST+xSxamCrBJ zXlnU*AhYcr7?v>nYY)t}eo>!%FcIU?tD>!`($fNc>8QaA?o7Ya!&5#2t2F{AAOc0E z;X3BX=fg-|Uxztkr18bbfDGPWZsGS&7qA6p#cnrEcdX1umFLl#DW^So%oig%C3l2{ zFMdA6WmY6yqX*`v42m#*!+5PI&;_XVXRF&&Z2=F2W6Y*LBIoPiW-|{}F|78;>ZF4V zT8|EA16{VO4>i>ua`N+-aRq0`cmm%Znf71tb5OAnEL!%a(lJvwM(>1At;wGIPa>YD z>Fklk@vs=u(|3Rj%PDE;Ik{wLdxaGJ9*$$QMmT(XMJe!QWpMD(1i&;rEDM%FxEn~G z4L4Oq?v?AN;_mb>9b|LZSawGOmf~+hXye@i7fg3h$jO?m_V++TKzNCwTTOAh{EIp= zk1uJzpLwkdFR))!8ZMTlM;;Eav$Ph7#;WTD{=M$VN$? zfJ+N`rk_#~YfB7FU!0U|qR&oL7sOi9*g)Lw*0KGzYdyFs88lAX9ENOL4wWfs)m$$K zsiEc}Bs3QbQMl;Xo97ja6{2yMk2Z^Z5bgNFs=G{n5q|X6R#Xck z=`p7iwQ`kr>z{n$lO5L$JH{Q)C>@Il@I54N<$UEt+VjLIDcyrr>6P9VSiAV$im%jV zG9oSWR6BYTK|y4v&oGLxo@zVBM|^}AMaZR>sd%-=L(+9HFR?G(RX+7@?x84Q)HdX3 z6vBtaFxdgk+b#AOcb*Zj*`*aN-1^Kq+CPydgq=L<#TQ+x_3%>GfW~8K2~vU< zWK#EL;cwMgqPmb{TqU$f#+RWLW#EtmP&L~AWm34j%j*dYf#R;x!$~5oNIo$PD%-{* z;)r}2TP7=JV!Vu*hWxNpkgChzDAf1hR*l~Im^Fa~V7)MRh%3AWNvr#t_D;qJC+Zvw8sHkjq=%6t&UaZ$53wFiEtyN~9q+XFj;rBmtT(sOIIkfnCgw+iaoHU`1 zx}l=I7Hn$s7UJ?X_Rr!PhRlf%v*n}4E?Y3)_-3w z{A<|y=-qt`S|4>@P8OE`X3+X*s{O5)^6?zFk4@zMSxot#r>u`M%YP}Rd_4DW#gxBF z<$s%G{?-is?Qt$o(2fQ7A<>8(yjkpwV7&3^it6kBYQt(Q4Ed$f2(GG|rRPO_H*uUP>Niu=MubY-+aE8fG|dY6_p-aUJGY6B`wX{p?){Eu{-l&p z7HV#y1`#A;WRnbA;x_RO_OfuXO^y|Fdg!sC+HHNwY`!)Df$JKf1Zs}08hc^A( zCUW@|M>7znNwkee1#^Vg(3cgup<;^u+xFXSPTGpc{vCmNgZhwf9WEL9l&{i2XZH6wvWh)#15>h-ocK6lhLa$9x*ppes72_w|8X;pYL1&U<%PO|8>NYVhvhhw8eFb-@1|Cx`XuwHOkoub-}*k5YawveapyD zGzDhj{v}NhbAK1V|gE3I|G|8T_5Inqaa>r zA8x#S)xwNE&|WCd7;yob~Me7vMpq< z&vYe5_Hr0NoBfnitbF|@zA`>6CN^_XHqe3TmHB3KpNlptj-{z`NhL&zuA0(W@ut$EHyQAcQhk5HY+JDmj+1-4XSY3Pakh8A~%!-q&}m`vNE@aosFsrKnj(aNm-GR z;yK=UU7db)kzzzuN+@O4jYOrQXNUpmHM>^B&|v=q=)6u6z(w2YS#{B@p+VUn;~0Gt z(7!1^Z@l>^AR$o@E56CdA2UHTO)&moqH5RbP?cm`%d>VwHqTfVm#b9|f6 za&xU?Jop3oS<@S(F?jmN{;z2`1Enz-V3fN7EL>JmsYc6SO^0AkybDh00u8qbDk_v#_6O=b-A{RV3Z;I9qS*X9cKIMK0BNpEW!i5@*=6rt?`x#+@C- z_uR?8n0(Fqrfktb=Trw(MWsAeDDfn%OZsEJ^jCb0?dM<&^Eq{)%XzqzoFE*WQ^7Y< z2a5s^(jDQV%6>+421xIR-b1pfOH_%RYnD{GYsb{~o$Q~McoY-^xng^XxexYVH??YZsL1=MC%TNL~u`_2In z>hzx8(p$N45AksiHF1jt*uV=7xn`{7Xx&XSt7T8U)8woMvt2O2!pu z=2X?x2ukze_5%A;fY8B)CWtVRGZg@vsR-K0r%y#3BQ}h(>PnSn2_p4!m@#?Divsrr(?Jm>J~HIj4IjM7p(r2uaYo3Nv>uJAFd^= zoh~YvX;g&$s0cIX`CXxuq^9sqU_`3Dondq5pdxSIPezP*;@mdPsg7xW$wUN zWN`Y&AA3#zLwE2IZv4ad@R#4^BjNaujN|XJga0k>_@^|&{y(G& ze|IYU%go^LcU8n+)`))z9sZV+d>9s-Z2oaZ|DKrqWo-C|yTRq}ZiSBw#r;Di;rV|g zDIa!@|5S|s>;C^zjQ{VG6gE!Q4?EAlcHwr^IHx@ z)bvV}9w^z3rF|J>nVj@mbyqP!o*+&gQ>=MSNmYrUj84LBmj8K7c3FqkBAZM3M2mtO z7BEQ-h!D?1VS^4PwPr(J%4TB29Fy2qOW5=!-l|~yGfnm7crlkcaU>HKedoMF60F5&2f;kD2q?3 zPu6M4v}{=Os|eZ*x&bZLG>u#)H)plj{>ZCTgh!i4qt#%U@;c6&)*5dV$(HkbuYK^Q zZLfuRUF23|Co7N5tnrE2OWzJb{5MJ%xwGFA!+E<Fa>C9F`fq#U&(+g zai=FOoHdSjQR&+AxND`vul86ebk?k*ecb-bf7O=Bq zoHh|aDm$BmfwwgYVS(^|D_Q5B*sY#CiSY0!%06)_x^Sc2*7{L4&0PO^bNPy)d5^Ri z#HC@01n4Bm?2knrMVtg+d=5&cI^b$X!RSC+ z1(<{?Efye!d57A8S|}CG?~RBIl12$La&(uv)uiTOAO@au>cs0zT~?bZLE$>^K0T%` z-tu%C^gij&`GzMQ;!NK_9Vc;EPJ&=Ia~7djKD7j(a|K}k z2tZskbnK6^Y30xQHPrFCRQJPGa02&4BFgb6N1WhI$rFd~VdH^nQ;J)u`GqPS7}R2H zvVG?A9LlLq&%D8f9iBHBEtgD2z@U*EesG?jE(sJsVZ6z}{0pP#W2?l9W0K>VW;+Ms zbkGxYw%7N;jI%Rhg83e;Qm$IQL_Zi6FKAF5WrN%;oOEP_xz!lAL<#0SQxLe(vFuHjIMZYudP4jZ3HPy&8q6ji6J zu55RIUg-{mrJL6bK&&AM_kQS@JqOXXKtwm>51}m%7^V|~ug~J`KFfrwK4hp%TRRi+ zN*|snTFF{XzNKa$C^TUDnV(a89oGviBrzg$74{fSKlo+u{>iWjN^VR}yxz@!C$cnj zsZ%UeB(qzMxZZ?V%>R{c$iBmQ90}wW2v8i&dnLOi1Oqxf#dTLd&;C+@^3Z5OI7?UP zT%ybC>ZC7V^nTm?G4aN+`ZHW!ACorEi3Tdl48?Y#{S8k~@w)9caJHe{rQ$#uo~tPo zd4Z`vCy_R-nW6H{*b@upPm0d4zpAV8``b*v#5~k}38^V5lUd6W2=0-+9vhaPIBb<{vY<<_R;|`05Vp#JI z53Vv)C(D9@gDFora5bP=SR=Prwc3<$5nx@_Cspom+bm_!tEPWbI)@fmMpN`0x_~Sb zYpRc#q1;;!NE}_fP^1dExE3hc2qNRB=|^8K`h|C4{`@T^J0K59nS0i>@GxMm>yz9( z89c$x*+b#w;n#k7pEB;u)_4UAV&R_*U=VPm56-_$LHq#(H#5}`0&=7U}p~BJxU%nivfMkeIJ(Qk=5j>)vPPV_bUvBbJJQ&Q z;YMuwbccoZ#3wo*?dKICmYi z&xDP9XpR0nYJg~dJUK##uj+u^a#(_?WoTnK#Z>SUs4-s$5o}&Lmw~08Qm`<;>aSf6 zqs`yn45x&ufr-96$$k!+>qcopm257QIl$S37vsE`Q`VIIzRM$K_$~3E3^uGf48S-T zed*{<_lwe$^QS}PQ_zq?TK$mkg~zB*hW;F}Cb-%QH2GI75L_>VsZ& zV0m60-nfr~Rtcq`t|st+T>u8@Vdh((MQ+TaNHyAsmUlWKrQkZ@}z1#w#=Y6?w3AKqF==(>%}yrJTl3 z^6G7zXg#PYfIza6{?ZJlDAA2!QZ5~1#$}qOJ%`P7$%`G<$X>;hom#o=!*FT$)r5%KZPA&fUJ2Wn=0~L zi@aMoE-x)Drx%-$=^0wUizUEmR{y<~?9k6ukJey~ruFp`|99ff3%{0~DU|nwY~Z;K zxp_TwQ{{FjjR7@vyJLxs*ahrdgADvvbBsueRSri-?7;Qlc|Cl>iKjt^s}%G&9n=!i zV1!~7Uy6l~`cD@cyt0%nM+rH*SHE>4fDhT|fkwhRan|E^T6Vshz`JqBut?gi(%oKf z{%5IvZYDBpWJ;Z5vQ4Ty8CgJ#KjKp-Lnksv{ozEMW}U9Ljzu}<+9^2k{CCJk*2O-H zMhtO&cEj8)h;O$($|o?E0Ioo%bCnB~OP|JRpKfO&_;2W*;;Mmki32XnSSs{yqX>a@Raa zSfHJnkjvofqc<)YVC}nzp>Gtq$SVVrbuLIW%4^vdMM@2w}8%q z@HD-{`%+tf8F51n)b}Pc7f-#l=Yhh#Ff@OEGg-%U{I;4JQ}KsISKQlSC;enp7N;|e z2+{OwlKMMZnVK^wrd-Xj^QOQ%Iq;YkhB5fI{fAYdxPO@`DOBzLitpt$;yaZ9 zN0Rl-ftzv60MxNfG@EO_%*Po?_*?K;`M4Kz=w}?H?=(iUbwyxBbk+WI&L=&ann)6J zMixvxTX2pVkkr_kS6`M!BuI2=f7owo@$r7r(TXhAv;<>8S%b7dG9FqX$fyB`g2B^! zN~mt2;!{R}{KnToTv)qKZ?Y@iT9W z;#ZVA*iO18>o=MJ)_}(EGU>EfcNMROxof_)o>*%ZecEsJf(TrB*G!zTW%I-rv;C9H zyeHG&&e~nm%pmYxh`#ap9H#FqpQu#M=F*YB@goGS^Xeq5)@8VD`E zaQ8X3H%>Ubkg1W$Du3_|x0lxdH!3y{Gx~bozb~x$Bl$&l{;4Se%{K8<%4B5X=BE_5 z4Jk#ER8HUL?+S*tkd-FAD856axSN0^@~Tz!9i7{mtdn42c%)f0EQeX0C6{5TWcbHx3iEA%>21bD#9pF~|{WA(DWgxopfb&$ss2?a20tR|ADNwe!Dy zL*YLOh4m@ctd+ivL2$pVvQ}FmS%$ma_6heUQd{WEl7WEaVy7loW;id2SvfP1VC#*< z?ne?6`%;$NPG6=HTI0iVBS?6JmcNB&>*8nw>HC@w7*@iy6s5AibUP~VMO}8Uc%aj7 z!9`~I^67Uk6;baorW_5acdKTTl@^XUbE+Np=6GU0gRnm5yPT#=eb^{igKo<+vg$d3 zG)Wz!L%nO+hBVgB1kp^xQfyZxKPW+KviEeC?#x+&()90Yw{%2#-P0XV#cL{3lt>5%{@}hsgDLJwAMM+-VIka{eHWLUuKQA4$Z#tg zy||{J;oShoi`9R4LHg=bAB{UlhEl#%2Ad$3FI04^Nk>8RvQ~fN`ns7$bDyjt_6&va zpmHcAcWBPx448oMw(uWJ8YXsULKODG6#3_D1$z~6WjQ&_t_%Sfwwl)jR-FdN+8u!l zd@d=UiBH;tQ`eX)-O}Tk80%ecB}&RSGAs!jmCq@USMZ4PI_(D%!sB0Ot7&=cm=;~O z^l;AJO}|xgW+;B@5k)H`#s~g^e7^i;hIaVkGc!eJdhYN=I>d>1*+?$Xov9!Yg|sn6 za(UCGs|tZ<)W6`ecfbTI=M&sK8%e+Px3I0z2V`C{2ui^ z)x$Iqd9A$c4e{2q4hNc)_>sc{kF&vsZMCG6qj@P)#bZC1>%Uzc3#}zL{*)s)M>7{H zE^|wU>@JdT9NsYSv1P{1*bu7Gx+%gJkWJHgp8kBj#LK!d%JW=t1Dh0v8)f;(yWQqz zRK4Y~F7;RQa5rPzO^vS@dgkFv4shjTDP+BAlkT7Gz^+Txj~|P$@yOws78amVI1-tj zB75405|I%y&(dgSydB9|Ki}0jmyO}3hC)NV3?K~>@I`j&>1d;^bYW{L%i@oGE4r?9 zY#tWx_|tb-<$a9AN#2XfJ{)T)PkS}&`(sYlc|F=uPKHF}goe13$LPHgl zeimi_#O^utRB?c%>=PJV+TJ;ZB0EZ(VV5mI%Ng}Ai=cl)QT{I%LH}kk&p!Sfd3F*$hVi)3u~J7fk9HBR(zje3vmFZqvc)pYOu zFq-Mlotw*>qkDRqN!cdTRzDZ#=)1`PjS=>hF7&%-h}vtbxS6CMu%xKAbhSHBEJ)!L z-qCzNk=pgg`Q^ZjryLpM+;(ZNZMfHdxPtUub5czW*V{Yz*_aBQrpLpt(G78H3iCx= z?+A2U480S%|6!dWx+2ZY$c!vVfi63}@~NYHK%FS%4E8{j6PIF8VNnz$>%a5x%8d_r zhJ%_*CO5{wRR=~NBBN94q6L?jxj#r}bmh-(O^{TY<~-ugD%?2tS{>69%X?dRmTCBM zdau+5DHqGRP+(n&VSo097tY+qL;>zeL8dPow;aAa-{l{0gW04ZHu8F>`Avo0WyTxj z#S@dpGXtrMsp1r2`p$E99XR8}UJtHGd>qOnFq_t`9fAKm8&#fonz_U!|ewH z^*1pSoJh7(+o~Y3wDscK3p4kYDqlrOkJxWr@h-0G7F=5CoUom_H8H*7vD@(EbO*O% zP&z8HB-Uf{Lc84MlutUtLbj(tGyR>Z*ING zB*hIOc%Cos1g!CKSse<*aM~=#_Qvzcm+dRoy=OG9ANw}sK3Wp*7^5dp452)G>Y4t` z@{6&|Fp}FUC)Q*-g+o=sPcp+9#dwv^sa-ChTr9e`tE4@Y-cNQW&c0WvY2^pheB;#X zJE>#I_jonVMV{)N!Pl6NNYWL#M_h@UE{=p<+njSv?l`^rc50LDRuiwBUHFB#&Z&B< zC+uRE8TpXV`}=1XV!zg0Bwx>YINkFR*R=L%jcJRg5%Z`rXtU6zI@ZeJ)-u}-^O?oB zmASFuRI5zg>WCem7hQ~E!xo7ub|!NH^7rK4EQ>PV{6QMCy%|~mM(I?BtM1II$BG8w z+_h=pB-DIVA}q@vwG^@DfPP_+t(A5KJJiL5-lsM8WuQ_r3avSb!-$1GmFPF7_2SH2iS>LloF*oU9V1o{Qf)N|yHCkQE`JH!Ij5(N*XoRB&PZU|3OUEp^93iQHD-R9%owsrvbMkHM;Y zffKj&baz=|91U6>;}Zt@%Nz1I3d=hTj4gCDRyiPTQoHX-)+<1n;%{#vZEHGuA6I8i zt!=6&86+}ZohZmnn(gfucJ0q#tt>C-&w!R6bdeMuz7Ig{Jf3k(>Yw{{``emkM zg73JDeTZ`Rd(Rx3`^(0KMR#q^VbQ`!XklXqp9oA0+9 zztd@J7pEAQiI_-MHJj)L`{u9M7mOwCdgO-wHM1N+#wvPXApw9=C9hmw*5yYCdD z9DmdN;YI_iPN%GB^?UQiA0BCq);hh{&C}$H`(PhLjNYBj{XW*7pb06mFaPQx_)qu1P}iTrJt&Ft;KA-%q}Nc1D& zCtt)5#n73K^M~5~HrBr)l`A*!Qw#a{GY(4Pz6TOPgO(jQhS` z*orDSW1Tu};(9|^oJE^I<2f(J;(VR|+Hi`Mp-Hl4fpGfL$A+NCUIlq8-y(F^MAmCv za<9*P{0hRJ)>BbM>q^tjwx(s3SWB)gnqF7#RCNhv1n58)-l?Vl?1l`bddka^J8A8;cwTkZL( z_@wftE549P3`><|O8QG;!fJEu-h3G9isN9BQCls|)X}V|)vc`JA4WF$z<2acYM2T! zc35X(>3O}pcC5^`^mW-|Ylq(%4m_euTO`U8n<n26qEBMRDzL2e;u#c~dxfoLvT?I`P&xfnI4nEA#PAKD;(ow_vV{7mFMIc^q^rG4 zuRc|uo5EXLWEmW|-9Hn*VCrKXUo|xu4Ce!6-zRfAPga#1y9A9At)nYE<8L*YrXEHN zYgpN}hGi~uRZBdtisB5)=|eZkfk3feRj5pME`~pMK6oX4XaDQE9s?!Hc;D-0MpCzL z=D_OBH(FfEQe~&8bjxzXD(+X@WqC?>g+iB(KR+?hHivX0WwL*9!PUmaHunt|CvH=8 z*yaP-F@+3i)u>X45hY%r%)sV<^{U_pVHu)pH?w z+Bi@8iLh~m9>??+T?vVx>Y^rDLBNGaY9k3A3?{8LPdsUz=;s;Q+mx0YQFId`RIc;m z&J=bscr}VDH#ceuGU2*8jeGAF+r<6!$tUbkLh-B2ZtRHLH9TMTiEh$!o}eQdJ6lFN z6gyr;kRp5_7!eW(Y=jwt97^cP>gigY5S@)C`xbLl3EPA<3X;6axN>%dctuWs^wyTx z3f&5Pg>~ipO9~%SA1)tS9~K`npVPO7XlqX_keD~2ZzxQ!6FdlI1Q_A+xWl;6xP}GA zORfMpV%eB+_&CHuaTsahT8>5Ywt}s*T%IhxWQ~W#Cah|1r}Y(4su&!J0e)f zX#$Rrza@R=3-M)&%cP$b$aLwnNZW$-Erh{jPiVAAE(IHe+yl^ehy_WgLP$V7t)wla zVjwnliYT%cqRbFIg8J(jbyYeyDliE<9Svz1iBfPq0YsYD<8ND%5N*K+L~Q;Q*1e8-e^b@iT>!48TDi1%}X&V6u}$R}_d(D3IT#|8^pVxQ{TLcK^D6#a_5^f{H_Q`FAZG~77o7&>HERa#sQL@pCA26htXJt~oAGybA$31bI z9K)}vYDsEo7Rb#>yq=9je%mD*_oX>Nj+3^ea)tbxTwdvojpI!7wLAoC*!RT`(clDZIq#3hgQoa)HF@Y z3jnMoN^|4M>8MCOZ$_xNavNEZcE>HoJMSnOBICEhv^4@FzMpfVaPt4=@J>2E`b7!6 zgxdy1s7W7atBLg#pXVMNDR^bGYOcv!%R9@H;2eztW#c4Eh`{PQ*H5LMJA z*Fy5~Wd)QjMGEN!qAv0-k^}N6V%7OLrp(|tg}r|O3esD_Kl3JQJ)=Al3_5j^ zIFwj~;rXXYeTo`eaGka1%gwSk=zzxEq=a?rLA3&U6P}l&cCX+1B_vF74Sp&RFpe#& zEaA3xL65X)x>M(yNmyqk%;{m<#p@?8kh2ockZ3E&-M{P0PLB`1Zovs=t>UC^B0`<3 z%*o69Rx}x{lS6elb@^H91w;O*DcPr-`Es;;BBYl>atWs_pkPWt;>%>`Nty{Ko}Lsu zK@}{gKt;Yx^dRIMh*6zrK>_oWt%telG<{RGt1Y+nev5RQbi?i=wr#e-?t*mVyC!#y z<=65K2)k+D`RAzzX9@xex{cFI<-QDzbo$+^i2TkwPd2z-Fk{?Znr69ZgX(m&L$!$< zoSe5Dv>j9`NS>M#c?*8q9)c#^L`575o^uR=K}6Sss}*QPKp%&lLy`Q7;r)!ASj;84HIFeSRB_Ro<3frpkkoi*O29uwCM)2x9*$HO4W9Jg}EzIBV>ou^XMBs&mT6=;>g4Z zO?iKc!@T?@zJ6qAc%06jeeh|NTt8?NeiE&FOVB09Sr;+?EUZ6J1?umkbbUFaHS8j7 z5&8G!X3wSFLFujjnwlCG&-wYv4<=Xp3v&(N?i%g`WS=6sgv`eed_tbOM8xu%$ecRqR_5p20ehEsT{=TmrdjIONB?u%DUXT9Z`)a zm{X0v>O8JWs(nH(bzP@D&+V0|$mb~&^pZm%dMVe$;Ijcj4w>JC%B^S$aJ*XLZ{<|- zs@zzsRT#^b$M;}I;3WTQwp=c9)>yN=IAWS!jfZi{}LPP*@6@Yd!N*2TbO zjw9LaV`o;duAU}g(ZcI#X6U{Of+0tBcK1p-rTd*K zZNVLuYf*b!zKwM@-yJV?i1aeGqiSj_m8{aLl${=<6WRrT*d!vFlOg#fxh3wFs@CM` z#$_DH(wFz-$xoywYEz>-8&tnOjH*M}sAek1$Mp|Myt?;eoQh70!*aG0s(+9d8#KR~ z;JvntXgZv3J`Cb%I%Bdl*D@iM@tLMp=Zt?>8hTIXq;hKX+>gg@`ath z(|eQBC~D{>V|J9mT{;c@{wGDP2$fyMfWxX>$1f#U&>C&u*6LZ~;#ku^H#!=|$2&VH zaWV48r}FK7uRLK)Y8WY*vm7p)?cbXlU`{vtQR-|7rklamie!Dr;PDEdym3Qp8n><+ zE_rQ0y&K<8;IGteW^#UAaYNsEZv1u$f91(!{?FWFGG|*kSh%52@pD7bulv3#DSqwq z6L|ja&HI?>>C+#_f9N?IO6k$hX>P(J9E^r}Xf?2#NjCQcvp$bh%hD>zgspU@<3#iX z#)b!19y0F#h*hVgCT-N&9KaJJa~T(n^GZtEP zT&T zL!NUy91G;pUaOiRQ8&~vdYyMyb05J(XsMk%^5`m)Od^zt>O?&e+Q< zo9T9VlAdVf?G>%3X(ejXx;wU~|1Exv?)}9vO5(#RcJkYYVyh8C23cB(P;}eF1SO;r#@RuY~gpd={2Qm5dU042gurft~?(vp=+aYmNu3+%l{Bu8f$C) zwd4$eA^x)!R94;xAXRii<3LtuI|o;k!1Aj)0g!_YO29}`8?5c7gtm83_rszM{4QUy z_H(jE+6c(YQOU|%+F-2EqCS>xqPE9;ryd~SoE?f+NncBq_ejA<+Q-Gs1qcJ=0no7u&Iu)8tgQ>eVKB~44mc203@HW?g}^R=Y_R}Cl{W_K1cHdc z1pt>=8(V38Wz|0&0(U3@dmPS9T3p=Q+gr>VCWgVpugil9E^XOxq65l$0P;-q=-8hi$lb~;(v8?^mX8fkJJI<3Oef8(hB2=L-F$d z>Ba4Dz5n6{cmVi;0e)nGrnD{=W8-O!#>%PbYJt@3F&?--+=7r|k}xrttoScVRL3@& zXkXxG+jz+R@$}~{m8|$tB!7$S&zJHy$-l)448*_xWMK6E{LQj}juKw&Y>7k5 zLBUX%C>Z#IUx7$ULm|=<5+Y!TG#D%^{+lK+(txQF77dvFPfY)&`8QJx_6jima=OZ@ zPzlhn3I_W}SAX6dKmOnTeoHrP?f?1>xVRkU`UL!c7YHj)r2DFN~S*-+S6|BS=U6YG4Ou{HpEsWbYh zOAlZ)AV45C*3z~ZtcxWMs2MjmfJ)8sC=HKN1;{GkA@e6r+!&zU|MK^{>G7i@)1L-F z+vY!I^8XmnFP{D-(T_zy`2#On{O9B%M+N%-bp8Kx^q(0OIEsK0@IIO^f!3c}?}66Q zC&IrS{P=5%5d8NkLI^muaWq0e1{~FwptAH)ae`rBI7AW(Ij&0qFjxRk1M2s`(YSJy zID&t9#9#$~DL6oW(GzD6h=CSxiog+VjXP@oH310<$;tg0z7G~{OLfFLN_A8Ue_X)V zS4n_T)E4xI41xsCKZA}Rfn5KT!KL65z`v@a>!%EYgaKb|f0u!en7V(_1A~zO@!_vB zfQ9rQwlFvxAW!{O4~7H-+^4_E;D{rR;-l+lEE0$#KILC!FexDD-(-@2;_tRF1QH;C z{nZu@jMVQkm=qigU|}6yKl_!0f&otFUu94j;vX>s?-byC{Y?)l`A=J@gv9UtLZK1> zdE~FQFgWyxN&V>h84Cmn1^AwSlYu4v84Ci5{M{Dt2OK~8)fR?4qJ;fbCV@cyBR)we z6!MSv0-T}$T?WwX0$KK}KNuVs_1|SuKz;l}21EQK4-n9QtirD(DAMSkhq4cEe3Q{=K8?)0%ee?EL2fK5vC%g zD5VOAC_xlq5Jd?|6{xZ#&_qI%Ri)(qdkr%x0EWN=XNkof7a&{;0aPGAzlx43)&Bvb Cy`duj literal 0 HcmV?d00001 diff --git a/backend/app/mecenat/number_words.py b/backend/app/mecenat/number_words.py new file mode 100644 index 0000000..6b00fbc --- /dev/null +++ b/backend/app/mecenat/number_words.py @@ -0,0 +1,84 @@ +"""Montant en toutes lettres, en français — remplit l'encadré « Somme en +toutes lettres » du cerfa (voir `pdf.py`). Fonction pure, testée séparément +de la génération PDF : l'orthographe des nombres en français a suffisamment +d'exceptions (accord de "quatre-vingts"/"cents", "et un" irrégulier à 71) +pour mériter ses propres cas de test. +""" + +_ONES = [ + "zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", + "dix", "onze", "douze", "treize", "quatorze", "quinze", "seize", + "dix-sept", "dix-huit", "dix-neuf", +] +_TENS_PREFIX = {2: "vingt", 3: "trente", 4: "quarante", 5: "cinquante", 6: "soixante"} + + +def _under_hundred(n: int) -> str: + """0 <= n <= 99.""" + if n < 20: + return _ONES[n] + if n < 70: + tens, unit = divmod(n, 10) + prefix = _TENS_PREFIX[tens] + if unit == 0: + return prefix + if unit == 1: + return f"{prefix} et un" + return f"{prefix}-{_ONES[unit]}" + if n < 80: + # 70-79 : soixante + dix..dix-neuf, « et onze » irrégulier à 71 (le + # seul cas des années 70 avec « et » — 72..79 n'en prennent pas). + rem = n - 60 + if rem == 11: + return "soixante et onze" + return f"soixante-{_ONES[rem]}" + # 80-99 : quatre-vingt(s) + ... — jamais de « et », y compris à 91. + rem = n - 80 + if rem == 0: + return "quatre-vingts" + return f"quatre-vingt-{_ONES[rem]}" + + +def _under_thousand(n: int) -> str: + """0 <= n <= 999.""" + if n < 100: + return _under_hundred(n) + hundreds, rem = divmod(n, 100) + prefix = "cent" if hundreds == 1 else f"{_ONES[hundreds]} cent" + if rem == 0: + # « cents » ne prend un accord au pluriel que sur un multiple exact + # (deux cents, mais deux cent un). + return f"{prefix}s" if hundreds > 1 else prefix + return f"{prefix} {_under_hundred(rem)}" + + +def _number_to_words(n: int) -> str: + """0 <= n <= 999 999 999 — largement suffisant pour une cotisation ou un + don, jamais invariable au-delà (« mille » ne prend jamais de « s »).""" + if n == 0: + return "zéro" + parts = [] + millions, n = divmod(n, 1_000_000) + if millions: + parts.append("un million" if millions == 1 else f"{_under_thousand(millions)} millions") + thousands, n = divmod(n, 1000) + if thousands: + parts.append("mille" if thousands == 1 else f"{_under_thousand(thousands)} mille") + if n: + parts.append(_under_thousand(n)) + return " ".join(parts) + + +def amount_in_words(amount: float) -> str: + """« Cent cinquante euros », « cinquante centimes », « un euro et dix + centimes »… — texte de l'encadré « Somme en toutes lettres » du cerfa, à + partir d'un montant en euros (`Volunteer.fee_amount`).""" + cents_total = round(amount * 100) + euros, cents = divmod(cents_total, 100) + parts = [] + if euros or not cents: + parts.append(f"{_number_to_words(euros)} {'euro' if euros <= 1 else 'euros'}") + if cents: + parts.append(f"{_number_to_words(cents)} {'centime' if cents <= 1 else 'centimes'}") + text = " et ".join(parts) + return text[:1].upper() + text[1:] diff --git a/backend/app/mecenat/pdf.py b/backend/app/mecenat/pdf.py new file mode 100644 index 0000000..14c10de --- /dev/null +++ b/backend/app/mecenat/pdf.py @@ -0,0 +1,132 @@ +"""Génération des reçus fiscaux (cerfa 11580*05) pour les dons « mécénat » +de la cotisation — voir `app/api/volunteers.py:generate_mecenat_receipts`. + +Module isolé, fonction pure (`generate_mecenat_receipts_pdf`) : aucun accès +base ici. Le cerfa officiel (`cerfa-mecenat-particuliers.pdf`, à côté de ce +fichier) n'est pas un formulaire PDF interactif (aucun champ AcroForm) — les +informations du donateur, du montant et de la date sont donc dessinées +par-dessus la page 2 du modèle, aux coordonnées mesurées sur ce document +précis (mise en page fixe d'un cerfa officiel, jamais destinée à changer). +Les informations de l'organisme bénéficiaire (page 1, encadré « Organisme +bénéficiaire ») ne sont volontairement pas renseignées : à compléter à la +main par la coordination avant remise au bénévole. +""" + +import datetime as dt +import io +from dataclasses import dataclass +from pathlib import Path + +from pypdf import PageObject, PdfReader, PdfWriter +from reportlab.lib.colors import black +from reportlab.pdfbase.pdfmetrics import stringWidth +from reportlab.pdfgen import canvas + +from app.mecenat.number_words import amount_in_words + +_TEMPLATE_PATH = Path(__file__).parent / "cerfa-mecenat-particuliers.pdf" + +_PAGE_W = 595.0 +_PAGE_H = 842.0 + +# `bottom` : ligne de base du champ imprimé (pointillés) sur lequel écrire, +# en points depuis le HAUT de la page (repère pdfplumber, utilisé pour +# mesurer ces coordonnées) ; `_NUDGE` replace le texte juste au-dessus de +# cette ligne plutôt que dessus, une fois converti en repère PDF (origine en +# bas — voir `_draw`). +_NUDGE = 1.5 + + +@dataclass(frozen=True) +class _Field: + x: float + bottom: float + max_width: float + font_size: float + bold: bool = False + + +_LAST_NAME = _Field(x=66, bottom=250.5, max_width=230, font_size=9) +_FIRST_NAME = _Field(x=352, bottom=250.5, max_width=190, font_size=9) +_ADDRESS = _Field(x=136, bottom=281.2, max_width=405, font_size=9) +_POSTAL_CODE = _Field(x=99, bottom=295.5, max_width=33, font_size=8) +_CITY = _Field(x=232, bottom=295.5, max_width=310, font_size=9) +_COUNTRY = _Field(x=67, bottom=310.5, max_width=125, font_size=9) +_AMOUNT = _Field(x=44, bottom=365.2, max_width=110, font_size=9) +_AMOUNT_IN_WORDS = _Field(x=356, bottom=363.1, max_width=190, font_size=8) +_PAYMENT_DATE = _Field(x=184, bottom=380.9, max_width=76, font_size=9) + + +@dataclass(frozen=True) +class MecenatReceipt: + """Un reçu = un bénévole ayant payé un type de paiement « mécénat » — + voir `Volunteer.mecenat_*`/`fee_*` (CLAUDE.md § 2, Paiement de la + cotisation).""" + + last_name: str + first_name: str + address: str + postal_code: str + city: str + country: str + amount: float + payment_date: dt.date + + +def _fitted_font_size(text: str, max_width: float, font_size: float, bold: bool) -> float: + """Réduit la taille de police jusqu'à ce que `text` tienne dans + `max_width` (plancher 6 pt, en dessous le texte reste simplement + tronqué par le tracé plutôt que rendu illisible).""" + font = "Helvetica-Bold" if bold else "Helvetica" + size = font_size + while size > 6 and stringWidth(text, font, size) > max_width: + size -= 0.5 + return size + + +def _draw(c: canvas.Canvas, text: str, field: _Field) -> None: + if not text: + return + font_size = _fitted_font_size(text, field.max_width, field.font_size, field.bold) + c.setFont("Helvetica-Bold" if field.bold else "Helvetica", font_size) + c.setFillColor(black) + c.drawString(field.x, _PAGE_H - field.bottom + _NUDGE, text) + + +def _overlay_page(receipt: MecenatReceipt) -> PageObject: + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H)) + _draw(c, receipt.last_name, _LAST_NAME) + _draw(c, receipt.first_name, _FIRST_NAME) + _draw(c, receipt.address, _ADDRESS) + _draw(c, receipt.postal_code, _POSTAL_CODE) + _draw(c, receipt.city, _CITY) + _draw(c, receipt.country, _COUNTRY) + _draw(c, f"{receipt.amount:.2f}".replace(".", ","), _AMOUNT) + _draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS) + _draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE) + c.showPage() + c.save() + buffer.seek(0) + return PdfReader(buffer).pages[0] + + +def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes: + """Un reçu = les 2 pages du cerfa officiel, page 2 complétée avec les + informations du donateur, du montant et de la date — voir le docstring + du module. Retourne les octets du PDF concaténant tous les reçus, un + bénévole après l'autre. Le modèle est relu à chaque itération : `merge_page` + mutant la page en place, réutiliser le même objet d'un tour à l'autre + accumulerait les tracés d'un bénévole sur la page du suivant.""" + writer = PdfWriter() + for receipt in receipts: + base = PdfReader(_TEMPLATE_PATH) + writer.add_page(base.pages[0]) + # `add_page` renvoie la page telle qu'attachée au writer — fusionner + # l'overlay sur ce retour plutôt que sur `base.pages[1]` (page encore + # détachée d'un writer, `merge_page` dépréciée dans ce cas). + donor_page = writer.add_page(base.pages[1]) + donor_page.merge_page(_overlay_page(receipt)) + buffer = io.BytesIO() + writer.write(buffer) + return buffer.getvalue() diff --git a/backend/app/migrations/versions/0019_form_access_token.py b/backend/app/migrations/versions/0019_form_access_token.py new file mode 100644 index 0000000..b8e85d6 --- /dev/null +++ b/backend/app/migrations/versions/0019_form_access_token.py @@ -0,0 +1,44 @@ +"""Jeton d'accès au formulaire public (lien non devinable) + +Revision ID: 0019_form_access_token +Revises: 0018_pending_submissions_mecenat +Create Date: 2026-09-30 + +`form_template.access_token` : secret partagé désormais nécessaire pour +atteindre le formulaire public (`/inscription/`) — voir CLAUDE.md § 2, +`FormTemplate`, et `app/api/forms.py:_require_access_token`. `/inscription` +seul, sans jeton dans l'URL, ne mène plus nulle part. + +Rétrocompatibilité : chaque `form_template` déjà en base reçoit un jeton +généré aléatoirement — les liens déjà partagés cessent de fonctionner, la +coordination doit en repartager un nouveau (« Copier le lien » sur l'écran +Inscriptions) — c'est précisément le but du changement. +""" + +import secrets +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0019_form_access_token" +down_revision: str | None = "0018_pending_submissions_mecenat" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "form_template", sa.Column("access_token", sa.String(), nullable=True) + ) + conn = op.get_bind() + for (row_id,) in conn.execute(sa.text("SELECT id FROM form_template")): + conn.execute( + sa.text("UPDATE form_template SET access_token = :token WHERE id = :id"), + {"token": secrets.token_urlsafe(16), "id": row_id}, + ) + op.alter_column("form_template", "access_token", nullable=False) + + +def downgrade() -> None: + op.drop_column("form_template", "access_token") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 75e9b11..468bc95 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -7,6 +7,7 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent """ import datetime as dt +import secrets import uuid from sqlalchemy import Boolean, CheckConstraint, Column, Integer, LargeBinary, Time @@ -524,6 +525,14 @@ class FormTemplate(TimestampMixin, table=True): # un secret, au même titre qu'un mot de passe SMTP. sumup_merchant_code: str | None = None sumup_api_key: str | None = None + # Secret partagé nécessaire pour atteindre le formulaire public + # (`GET .../form/public`, `GET .../form/logo`, `POST .../form/public/submit` + # — voir `app/api/forms.py:_require_access_token`) : `/inscription` seul, + # sans ce jeton dans l'URL, ne mène nulle part. Généré aléatoirement à la + # création de la ligne (`secrets.token_urlsafe`) ; régénérable par la + # coordination (`POST .../form/regenerate-token`) pour révoquer un lien + # qui aurait fuité — l'ancien cesse alors immédiatement de fonctionner. + access_token: str = Field(default_factory=lambda: secrets.token_urlsafe(16)) class FormPaymentOption(TimestampMixin, table=True): diff --git a/backend/app/seed.py b/backend/app/seed.py index 261e049..9a51c96 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -25,6 +25,7 @@ from app.models import ( Availability, DayPart, Edition, + FeePaymentStatus, Position, PositionCategory, Role, @@ -203,6 +204,14 @@ ARTIST_SLOTS: list[tuple[dt.time, dt.time]] = [ (dt.time(21, 30), dt.time(23, 0)), ] +# Adresses postales des bénévoles « mécénat » — voir `_add_demo_payments`. +MECENAT_ADDRESSES = [ + ("12 rue des Lilas", "75020", "Paris"), + ("3 impasse du Petit Bois", "44000", "Nantes"), + ("8 avenue de la République", "69003", "Lyon"), + ("21 chemin des Vignes", "33000", "Bordeaux"), +] + @dataclass class Summary: @@ -216,6 +225,8 @@ class Summary: position_wishes: int = 0 artist_wishes: int = 0 companion_wishes: int = 0 + fee_paid_classique: int = 0 + fee_paid_mecenat: int = 0 warnings: Counter = field(default_factory=Counter) blocking: int = 0 @@ -232,6 +243,8 @@ class Summary: f" Vœux de poste déclarés : {self.position_wishes}", f" Vœux d'artiste déclarés : {self.artist_wishes}", f" Vœux de compagnon déclarés : {self.companion_wishes}", + f" Cotisation payée (classique) : {self.fee_paid_classique}", + f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}", f" Avertissements actifs : {sum(self.warnings.values())}", ] for code, count in sorted(self.warnings.items()): @@ -512,6 +525,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary: n_assignments += _add_demo_warnings( session, edition, shifts, reserved, assigned, availabilities ) + n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng) session.flush() fill: Counter = Counter() @@ -537,6 +551,8 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary: position_wishes=n_position_wishes, artist_wishes=n_artist_wishes, companion_wishes=n_companion_wishes, + fee_paid_classique=n_fee_classique, + fee_paid_mecenat=n_fee_mecenat, ) _verify(session, edition, summary) return summary @@ -610,6 +626,44 @@ def _add_demo_warnings( return added +def _add_demo_payments( + session: Session, volunteers: list[Volunteer], rng: random.Random +) -> tuple[int, int]: + """Quelques bénévoles ayant payé leur cotisation via SumUp — voir + CLAUDE.md § 2, Paiement de la cotisation. `fee_payment_label`/ + `fee_amount` sont une copie figée (comme en production) : aucun + `FormPaymentOption` n'a besoin d'exister pour autant. Pour que l'export + Excel et les reçus fiscaux mécénat (`/editions/{id}/volunteers/ + mecenat-receipts`) aient de quoi montrer quelque chose en démonstration.""" + pool = rng.sample(volunteers, k=12) + classique, mecenat = pool[:8], pool[8:] + + for volunteer in classique: + volunteer.fee_payment_label = "Cotisation classique" + volunteer.fee_amount = 15.0 + volunteer.fee_payment_status = FeePaymentStatus.PAID + volunteer.fee_paid_at = dt.datetime.combine( + START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21)) + ) + session.add(volunteer) + + for i, volunteer in enumerate(mecenat): + address, postal_code, city = MECENAT_ADDRESSES[i % len(MECENAT_ADDRESSES)] + volunteer.fee_payment_label = "Mécénat" + volunteer.fee_amount = rng.choice([30.0, 50.0, 80.0, 120.0]) + volunteer.fee_payment_status = FeePaymentStatus.PAID + volunteer.fee_paid_at = dt.datetime.combine( + START - dt.timedelta(days=rng.randint(5, 45)), dt.time(rng.randint(9, 21)) + ) + volunteer.mecenat_address = address + volunteer.mecenat_postal_code = postal_code + volunteer.mecenat_city = city + volunteer.mecenat_country = "France" + session.add(volunteer) + + return len(classique), len(mecenat) + + def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None: plain = sorted(shifts, key=lambda s: s.starts_at) for i, first in enumerate(plain): diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6125c5f..adf2551 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,10 +12,15 @@ dependencies = [ "pydantic-settings>=2.6", "python-multipart>=0.0.12", "openpyxl>=3.1", - "pyjwt>=2.9", + # >=2.15 : aucune des CVE corrigées (toutes autour de PyJWKClient/JWKS, + # jamais utilisés ici — seul HS256 à secret symétrique statique est en + # jeu, voir app/auth/tokens.py) n'est exploitable dans cette appli, mais + # autant ne pas rester sur une version listée vulnérable sans raison. + "pyjwt>=2.15", "bcrypt>=4.1", "ortools>=9.11", "reportlab>=4.2", + "pypdf>=5.1", "pillow>=11.0", "httpx>=0.27", ] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5a5075c..3029e68 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -11,6 +11,7 @@ from app.auth.rate_limit import ( form_submission_limiter, login_limiter, magic_link_limiter, + pending_status_limiter, ) from app.config import get_settings from app.db import get_session @@ -36,6 +37,7 @@ def _reset_rate_limiters(): login_limiter.reset() magic_link_limiter.reset() form_submission_limiter.reset() + pending_status_limiter.reset() yield diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py index b0ba522..dff96b7 100644 --- a/backend/tests/test_api_volunteers.py +++ b/backend/tests/test_api_volunteers.py @@ -3,9 +3,10 @@ import io import uuid from openpyxl import load_workbook +from pypdf import PdfReader from sqlmodel import select -from app.models import Assignment, Availability, Role, Volunteer +from app.models import Assignment, Availability, FeePaymentStatus, Role, Volunteer from tests.factories import ( dt_at, make_artist, @@ -426,7 +427,102 @@ def test_export_volunteers_as_excel(client, edition, session): assert row[6] == "Chilla" +def test_export_neutralizes_formula_injection(client, edition, session): + # CWE-1236 : un bénévole inscrit via le formulaire public (sans + # authentification) contrôle son nom, son commentaire, etc. — ouverts + # tels quels dans Excel par la coordination, une valeur comme + # `=cmd|'/c calc'!A0` y serait exécutée comme une formule. Un préfixe + # `'` force l'interprétation en texte (voir `app/exporting/xlsx.py`). + volunteer = make_volunteer( + session, edition, first_name="=cmd|'/c calc'!A0", last_name="Martin" + ) + client.patch( + _url(edition, f"/{volunteer.id}"), + json={"comment": "@SUM(1+1)*cmd|' /C calc'!A0"}, + ) + + resp = client.get(_url(edition, "/export")) + workbook = load_workbook(io.BytesIO(resp.content)) + row = list(workbook.active.iter_rows(values_only=True))[1] + assert row[0] == "'=cmd|'/c calc'!A0" + assert row[17] == "'@SUM(1+1)*cmd|' /C calc'!A0" + + def test_export_volunteers_is_coordinator_only(edition, make_account, as_account): lead = make_account(Role.LEAD, email="lead@e.fr") resp = as_account(lead).get(_url(edition, "/export")) assert resp.status_code == 403 + + +def _mark_mecenat_paid(session, volunteer, *, amount=50.0): + """Simule l'état d'un bénévole ayant payé un type de paiement « mécénat » + (voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le + marqueur qui distingue ce chemin d'un simple paiement classique.""" + volunteer.fee_payment_status = FeePaymentStatus.PAID + volunteer.fee_amount = amount + volunteer.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0) + volunteer.mecenat_address = "12 rue des Lilas" + volunteer.mecenat_city = "Paris" + volunteer.mecenat_postal_code = "75020" + volunteer.mecenat_country = "France" + session.add(volunteer) + session.commit() + + +def test_mecenat_receipts_pdf_includes_only_paid_mecenat_donors( + client, edition, session +): + paid_mecenat = make_volunteer( + session, edition, first_name="Alex", last_name="Martin" + ) + _mark_mecenat_paid(session, paid_mecenat) + + # Cotisation classique payée (pas de mécénat) : ne doit pas apparaître. + paid_classique = make_volunteer( + session, edition, first_name="Sam", last_name="Petit" + ) + paid_classique.fee_payment_status = FeePaymentStatus.PAID + paid_classique.fee_amount = 15.0 + paid_classique.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0) + session.add(paid_classique) + session.commit() + + # Mécénat choisi mais paiement non abouti : ne doit pas apparaître. + failed_mecenat = make_volunteer( + session, edition, first_name="Jo", last_name="Dupuis" + ) + failed_mecenat.fee_payment_status = FeePaymentStatus.FAILED + failed_mecenat.mecenat_address = "1 rue X" + session.add(failed_mecenat) + session.commit() + + resp = client.get(_url(edition, "/mecenat-receipts")) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/pdf" + assert f"recus-mecenat-{edition.year}.pdf" in resp.headers["content-disposition"] + + reader = PdfReader(io.BytesIO(resp.content)) + # Un reçu = 2 pages (le cerfa officiel) — un seul bénévole qualifie ici. + assert len(reader.pages) == 2 + donor_page_text = reader.pages[1].extract_text() + assert "Martin" in donor_page_text + assert "Alex" in donor_page_text + assert "50,00" in donor_page_text + assert "05/03/2026" in donor_page_text + assert "Petit" not in donor_page_text + + +def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition): + resp = client.get(_url(edition, "/mecenat-receipts")) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "mecenat.no_receipts" + + +def test_mecenat_receipts_is_coordinator_only( + edition, session, make_account, as_account +): + volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin") + _mark_mecenat_paid(session, volunteer) + lead = make_account(Role.LEAD, email="lead@e.fr") + resp = as_account(lead).get(_url(edition, "/mecenat-receipts")) + assert resp.status_code == 403 diff --git a/backend/tests/test_email.py b/backend/tests/test_email.py index c5f1e5c..4134a07 100644 --- a/backend/tests/test_email.py +++ b/backend/tests/test_email.py @@ -106,3 +106,32 @@ def test_render_magic_link_uses_translations(): assert subject assert "https://x/connexion?token=abc" in body assert "30" in body + + +def test_message_without_logo_has_no_inline_image(): + message = sender.build_message("a@b.fr", "Sujet", "Corps") + html_part = message.get_body(preferencelist=("html",)) + assert "cid:" not in html_part.get_content() + assert not any(part.get_content_type() == "image/png" for part in message.walk()) + + +def test_logo_is_embedded_inline_never_as_an_external_url(): + # Le logo est une pièce jointe « inline » (Content-ID), jamais une URL : + # `GET .../form/logo` exige le `access_token` du formulaire public + # depuis l'étape 27 — une URL externe dans l'email l'aurait exposé à + # quiconque lit la source du message (voir `app/email/branding.py`). + logo_bytes = b"\x89PNG\r\n\x1a\n" + b"fake-png-bytes" + message = sender.build_message( + "a@b.fr", "Sujet", "Corps", logo=(logo_bytes, "image/png") + ) + + html_part = message.get_body(preferencelist=("html",)) + assert "cid:edition-logo" in html_part.get_content() + assert "access_token" not in html_part.get_content() + assert "http://" not in html_part.get_content() + assert "https://" not in html_part.get_content() + + image_parts = [p for p in message.walk() if p.get_content_type() == "image/png"] + assert len(image_parts) == 1 + assert image_parts[0].get_content() == logo_bytes + assert image_parts[0]["Content-ID"] == "" diff --git a/backend/tests/test_forms_api.py b/backend/tests/test_forms_api.py index 3cf832a..942cf37 100644 --- a/backend/tests/test_forms_api.py +++ b/backend/tests/test_forms_api.py @@ -6,14 +6,27 @@ import datetime as dt import io import uuid -from app.auth.rate_limit import form_submission_limiter -from app.models import PendingFormSubmission, Role, local_now +import pytest + +from app.auth.rate_limit import form_submission_limiter, pending_status_limiter +from app.models import FormTemplate, PendingFormSubmission, Role, local_now from app.sumup import SumUpCheckout from tests.factories import make_artist, make_position from tests.mail import plain_text _MERCHANT = "MXXXXX" _API_KEY = "sup_sk_secret" +# `FormTemplate.access_token` est aléatoire par défaut (voir +# `app/models/domain.py`) — fixé pour toute inscription en attente créée +# par ce module, pour ne pas avoir à le relire dynamiquement dans chaque +# test (voir `_fixed_access_token` plus bas). +_ACCESS_TOKEN = "test-access-token" + + +@pytest.fixture(autouse=True) +def _fixed_access_token(session, edition): + session.add(FormTemplate(edition_id=edition.id, access_token=_ACCESS_TOKEN)) + session.commit() def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique"): @@ -58,7 +71,14 @@ def _form_url(edition, suffix=""): return f"/editions/{edition.id}/form{suffix}" +def _public(edition, suffix): + """`GET .../form/public` et `GET .../form/logo` exigent `?token=...` en + plus de l'édition (voir `app/api/forms.py:_require_access_token`).""" + return f"{_form_url(edition, suffix)}?token={_ACCESS_TOKEN}" + + def _submit(client, edition, **fields): + fields.setdefault("access_token", _ACCESS_TOKEN) return client.post(_form_url(edition, "/public/submit"), data=fields) @@ -112,11 +132,55 @@ def test_clearing_description_with_an_explicit_null(client, edition): assert resp.json()["description"] is None +def test_public_form_requires_the_access_token(raw_client, edition): + resp = raw_client.get(_form_url(edition, "/public")) + assert resp.status_code == 422 # `token` absent + + resp = raw_client.get(_form_url(edition, "/public?token=wrong")) + assert resp.status_code == 404 + assert resp.json()["detail"]["code"] == "error.not_found" + + +def test_submitting_without_the_right_access_token_is_rejected(raw_client, edition): + resp = raw_client.post( + _form_url(edition, "/public/submit"), + data={ + "first_name": "Alex", + "last_name": "Martin", + "hcaptcha_token": "x", + "access_token": "wrong", + }, + ) + assert resp.status_code == 404 + assert resp.json()["detail"]["code"] == "error.not_found" + + +def test_regenerating_the_access_token_invalidates_the_old_link( + client, raw_client, edition +): + old_token = client.get(_form_url(edition)).json()["access_token"] + assert old_token == _ACCESS_TOKEN + + resp = client.post(_form_url(edition, "/regenerate-token")) + assert resp.status_code == 200 + new_token = resp.json()["access_token"] + assert new_token != old_token + + assert raw_client.get(_form_url(edition, f"/public?token={old_token}")).status_code == 404 + assert raw_client.get(_form_url(edition, f"/public?token={new_token}")).status_code == 200 + + +def test_regenerate_token_is_coordinator_only(edition, make_account, as_account): + lead = make_account(Role.LEAD, email="lead@e.fr") + resp = as_account(lead).post(_form_url(edition, "/regenerate-token")) + assert resp.status_code == 403 + + def test_sumup_not_configured_by_default(client, raw_client, edition): body = client.get(_form_url(edition)).json() assert body["sumup_merchant_code"] is None assert body["sumup_api_key_set"] is False - public = raw_client.get(_form_url(edition, "/public")).json() + public = raw_client.get(_public(edition, "/public")).json() assert public["sumup_enabled"] is False assert public["payment_options"] == [] @@ -134,10 +198,10 @@ def test_setting_sumup_credentials_never_echoes_the_api_key(client, raw_client, # Identifiants seuls, sans aucun type de paiement : pas encore activable. client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]}) - assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is False + assert raw_client.get(_public(edition, "/public")).json()["sumup_enabled"] is False client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}) - assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is True + assert raw_client.get(_public(edition, "/public")).json()["sumup_enabled"] is True def test_payment_options_start_empty(client, edition): @@ -162,7 +226,7 @@ def test_creating_and_listing_payment_options(client, raw_client, edition): assert [o["label"] for o in listed] == ["Cotisation classique", "Mécénat"] client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]}) - public_options = raw_client.get(_form_url(edition, "/public")).json()["payment_options"] + public_options = raw_client.get(_public(edition, "/public")).json()["payment_options"] assert [o["label"] for o in public_options] == ["Cotisation classique", "Mécénat"] assert [o["amount"] for o in public_options] == [20, 50] @@ -251,7 +315,7 @@ def test_logo_is_coordinator_only(edition, make_account, as_account): def test_no_logo_by_default(client, raw_client, edition): assert client.get(_form_url(edition)).json()["has_logo"] is False - resp = raw_client.get(_form_url(edition, "/logo")) + resp = raw_client.get(_public(edition, "/logo")) assert resp.status_code == 404 @@ -264,7 +328,7 @@ def test_uploading_a_logo_and_reading_it_back_publicly(client, raw_client, editi assert client.get(_form_url(edition)).json()["has_logo"] is True - logo = raw_client.get(_form_url(edition, "/logo")) + logo = raw_client.get(_public(edition, "/logo")) assert logo.status_code == 200 assert logo.headers["content-type"] == "image/png" assert logo.content == _PNG @@ -285,7 +349,7 @@ def test_public_config_reports_the_logo_and_description(client, raw_client, edit _form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")} ) - body = raw_client.get(_form_url(edition, "/public")).json() + body = raw_client.get(_public(edition, "/public")).json() assert body["description"] == "Bienvenue au festival !" assert body["has_logo"] is True @@ -296,7 +360,7 @@ def test_public_form_hides_positions_and_artists_unless_enabled( make_position(session, edition, name="Bar central") make_artist(session, edition, name="Chilla") - resp = raw_client.get(_form_url(edition, "/public")) + resp = raw_client.get(_public(edition, "/public")) body = resp.json() assert body["positions"] == [] assert body["artists"] == [] @@ -305,7 +369,7 @@ def test_public_form_hides_positions_and_artists_unless_enabled( _form_url(edition, "/fields"), json={"fields": ["POSITION_WISHES", "ARTIST_WISHES"]}, ) - resp = raw_client.get(_form_url(edition, "/public")) + resp = raw_client.get(_public(edition, "/public")) body = resp.json() assert [p["name"] for p in body["positions"]] == ["Bar central"] assert [a["name"] for a in body["artists"]] == ["Chilla"] @@ -429,7 +493,12 @@ def test_submitting_with_a_photo(raw_client, client, edition): client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]}) resp = raw_client.post( _form_url(edition, "/public/submit"), - data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"}, + data={ + "first_name": "Alex", + "last_name": "Martin", + "hcaptcha_token": "x", + "access_token": _ACCESS_TOKEN, + }, files={"photo": ("selfie.png", io.BytesIO(_PNG), "image/png")}, ) assert resp.status_code == 201 @@ -447,13 +516,41 @@ def test_submitting_an_unsupported_photo_type_is_rejected(raw_client, client, ed client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]}) resp = raw_client.post( _form_url(edition, "/public/submit"), - data={"first_name": "Alex", "last_name": "Martin", "hcaptcha_token": "x"}, + data={ + "first_name": "Alex", + "last_name": "Martin", + "hcaptcha_token": "x", + "access_token": _ACCESS_TOKEN, + }, files={"photo": ("selfie.gif", b"not-a-real-gif", "image/gif")}, ) assert resp.status_code == 422 assert resp.json()["detail"]["code"] == "form.photo_type_not_supported" +def test_submitting_a_fake_photo_with_a_spoofed_content_type_is_rejected( + raw_client, client, edition +): + # Endpoint public, sans authentification : le `Content-Type` du fichier + # n'est qu'un en-tête fourni par l'appelant, jamais vérifié par + # lui-même — un fichier qui prétend être une image sans en être une + # planterait sinon la génération des badges/feuilles de poste plus tard + # (voir `app/api/forms.py:_read_photo`). + client.put(_form_url(edition, "/fields"), json={"fields": ["PHOTO"]}) + resp = raw_client.post( + _form_url(edition, "/public/submit"), + data={ + "first_name": "Alex", + "last_name": "Martin", + "hcaptcha_token": "x", + "access_token": _ACCESS_TOKEN, + }, + files={"photo": ("selfie.png", b"this is not actually a png file", "image/png")}, + ) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "form.photo_type_not_supported" + + def test_submitting_more_than_two_position_wishes_is_rejected( raw_client, client, edition, session ): @@ -884,3 +981,24 @@ def test_submitting_is_rate_limited_per_edition(raw_client, edition): raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x" ) assert resp.status_code == 429 + + +def test_pending_status_polling_is_rate_limited_per_token( + raw_client, client, edition, monkeypatch +): + # Chaque appel relit l'état réel auprès de SumUp (voir + # `get_pending_status`) — sans limite, un jeton connu (ex. une + # inscription abandonnée par son propre auteur) permettrait de + # solliciter l'API SumUp de l'édition indéfiniment. + option_id = _configure_sumup(client, edition) + monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: None) + resp = _submit( + raw_client, edition, + first_name="Alex", last_name="Martin", hcaptcha_token="x", + payment_option_id=option_id, + ) + token = resp.json()["pending_token"] + + for _ in range(pending_status_limiter.max_attempts): + pending_status_limiter.hit(token) + assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 429 diff --git a/backend/tests/test_mecenat_number_words.py b/backend/tests/test_mecenat_number_words.py new file mode 100644 index 0000000..60923af --- /dev/null +++ b/backend/tests/test_mecenat_number_words.py @@ -0,0 +1,59 @@ +from app.mecenat.number_words import amount_in_words + + +def test_zero_centimes_only_euros(): + assert amount_in_words(15.0) == "Quinze euros" + + +def test_singular_euro_has_no_s(): + assert amount_in_words(1.0) == "Un euro" + + +def test_et_un_forms(): + assert amount_in_words(21.0) == "Vingt et un euros" + assert amount_in_words(31.0) == "Trente et un euros" + assert amount_in_words(61.0) == "Soixante et un euros" + + +def test_seventies_irregular_et_onze(): + assert amount_in_words(70.0) == "Soixante-dix euros" + assert amount_in_words(71.0) == "Soixante et onze euros" + assert amount_in_words(72.0) == "Soixante-douze euros" + assert amount_in_words(79.0) == "Soixante-dix-neuf euros" + + +def test_eighties_no_et_ever(): + assert amount_in_words(80.0) == "Quatre-vingts euros" + assert amount_in_words(81.0) == "Quatre-vingt-un euros" + assert amount_in_words(91.0) == "Quatre-vingt-onze euros" + assert amount_in_words(99.0) == "Quatre-vingt-dix-neuf euros" + + +def test_hundreds_agreement(): + assert amount_in_words(100.0) == "Cent euros" + assert amount_in_words(101.0) == "Cent un euros" + assert amount_in_words(200.0) == "Deux cents euros" + assert amount_in_words(201.0) == "Deux cent un euros" + + +def test_thousands_are_invariable(): + assert amount_in_words(1000.0) == "Mille euros" + assert amount_in_words(2000.0) == "Deux mille euros" + assert amount_in_words(1234.0) == "Mille deux cent trente-quatre euros" + + +def test_cents_only_when_no_euros(): + assert amount_in_words(0.5) == "Cinquante centimes" + assert amount_in_words(0.01) == "Un centime" + + +def test_euros_and_cents_joined_with_et(): + assert amount_in_words(1234.56) == ( + "Mille deux cent trente-quatre euros et cinquante-six centimes" + ) + assert amount_in_words(1.01) == "Un euro et un centime" + + +def test_rounds_to_the_nearest_cent(): + # Erreurs de flottant (ex. 0.1 + 0.2) ne doivent jamais se voir. + assert amount_in_words(19.999999) == "Vingt euros" diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index be634f0..b974f1b 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -1,6 +1,6 @@ from sqlmodel import select -from app.models import Edition, Volunteer +from app.models import Edition, FeePaymentStatus, Volunteer from app.seed import DEMO_EDITION_NAME, build @@ -21,6 +21,9 @@ def test_build_creates_a_realistic_dataset(session): assert summary.position_wishes > 0 assert summary.artist_wishes > 0 assert summary.companion_wishes > 0 + # Paiement de la cotisation : au moins un exemple de chaque chemin. + assert summary.fee_paid_classique > 0 + assert summary.fee_paid_mecenat > 0 edition = session.exec( select(Edition).where(Edition.name == DEMO_EDITION_NAME) @@ -30,6 +33,19 @@ def test_build_creates_a_realistic_dataset(session): ).all() assert len(volunteers) == 120 + paid = [v for v in volunteers if v.fee_payment_status == FeePaymentStatus.PAID] + assert len(paid) == summary.fee_paid_classique + summary.fee_paid_mecenat + mecenat = [v for v in paid if v.mecenat_address is not None] + assert len(mecenat) == summary.fee_paid_mecenat + for volunteer in mecenat: + assert volunteer.mecenat_city and volunteer.mecenat_postal_code + assert volunteer.mecenat_country == "France" + assert volunteer.fee_payment_label == "Mécénat" + classique = [v for v in paid if v.mecenat_address is None] + for volunteer in classique: + assert volunteer.fee_payment_label == "Cotisation classique" + assert volunteer.fee_amount == 15.0 + def test_build_is_deterministic_and_idempotent(session): first = build(session) @@ -47,6 +63,8 @@ def test_build_is_deterministic_and_idempotent(session): assert second.position_wishes == first.position_wishes assert second.artist_wishes == first.artist_wishes assert second.companion_wishes == first.companion_wishes + assert second.fee_paid_classique == first.fee_paid_classique + assert second.fee_paid_mecenat == first.fee_paid_mecenat editions = session.exec( select(Edition).where(Edition.name == DEMO_EDITION_NAME) diff --git a/backend/uv.lock b/backend/uv.lock index 8a5a993..8b0dfd7 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -113,6 +113,7 @@ dependencies = [ { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "pypdf" }, { name = "python-multipart" }, { name = "reportlab" }, { name = "sqlmodel" }, @@ -137,7 +138,8 @@ requires-dist = [ { name = "pillow", specifier = ">=11.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic-settings", specifier = ">=2.6" }, - { name = "pyjwt", specifier = ">=2.9" }, + { name = "pyjwt", specifier = ">=2.15" }, + { name = "pypdf", specifier = ">=5.1" }, { name = "python-multipart", specifier = ">=0.0.12" }, { name = "reportlab", specifier = ">=4.2" }, { name = "sqlmodel", specifier = ">=0.0.22" }, @@ -629,11 +631,20 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860 }, +] + +[[package]] +name = "pypdf" +version = "6.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ac/63d71aaedb59acbcdef491e6ca6469165e3771c9c74358204818fd9bc5a6/pypdf-6.19.0.tar.gz", hash = "sha256:bbc43aca292369ccc6cbc8a921991ecf2538a3587ab5a116eff06c321d647155", size = 7033266 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl", hash = "sha256:7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14", size = 395480 }, ] [[package]] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index b89c699..dea5dad 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -9,9 +9,12 @@ server { server_tokens off; # RGPD / sécurité : les liens magiques (`/connexion?token=…`, un jeton de - # connexion à usage unique) transitent par cette route. Pas de journal - # d'accès ici — un jeton en clair dans un log de conteneur serait un vrai - # moyen de se connecter à la place de quelqu'un pendant sa durée de vie. + # connexion à usage unique) et le lien d'inscription partagé par la + # coordination (`/inscription/`, voir CLAUDE.md § 2, + # `FormTemplate`) transitent tous les deux par cette route. Pas de + # journal d'accès ici — un jeton en clair dans un log de conteneur + # serait un vrai moyen de se connecter à la place de quelqu'un (ou de + # deviner le lien d'inscription) pendant sa durée de vie. access_log off; # Postures de base pour une appli publique : pas de sniffing de type MIME, diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index 18b3ecd..fa788a2 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -94,7 +94,9 @@ async function checkPublicForm() { await api.forms.setFields(e, { fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT', 'WILLING_TO_PAY_FEE'], }); - const config = await api.forms.getPublicConfig(e); + const template = await api.forms.getTemplate(e); + const formToken = template.access_token; + const config = await api.forms.getPublicConfig(e, formToken); if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut'); if (config.positions.length !== positions.length) { throw new Error('formulaire public : postes manquants dans la config publique'); @@ -107,7 +109,7 @@ async function checkPublicForm() { // qu'exécuté ici) ; seul le texte d'introduction et la config SumUp sont // vérifiables ici. await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' }); - const configWithDescription = await api.forms.getPublicConfig(e); + const configWithDescription = await api.forms.getPublicConfig(e, formToken); if (configWithDescription.description !== 'Bienvenue au festival de test !') { throw new Error("formulaire public : le texte d'introduction ne revient pas correctement"); } @@ -127,7 +129,7 @@ async function checkPublicForm() { amount: 15, }); await api.forms.paymentOptions.create(e, { label: 'Mécénat', amount: 50 }); - const configWithSumup = await api.forms.getPublicConfig(e); + const configWithSumup = await api.forms.getPublicConfig(e, formToken); if (!configWithSumup.sumup_enabled || configWithSumup.payment_options.length !== 2) { throw new Error('formulaire public : la configuration SumUp ne revient pas correctement'); } @@ -136,7 +138,7 @@ async function checkPublicForm() { let tooManyArtistsRejectedWith = 'non'; if (sameDay.length >= 2) { try { - await api.forms.submitPublic(e, { + await api.forms.submitPublic(e, formToken, { first_name: 'Test', last_name: 'Smoke', hcaptcha_token: 'dev', @@ -151,7 +153,7 @@ async function checkPublicForm() { let tooManyPositionsRejectedWith = 'non'; if (positions.length >= 3) { try { - await api.forms.submitPublic(e, { + await api.forms.submitPublic(e, formToken, { first_name: 'Test', last_name: 'Smoke', hcaptcha_token: 'dev', @@ -165,7 +167,7 @@ async function checkPublicForm() { // Paiement en ligne choisi : rien en base tant que la « banque » (mock) ne // confirme pas — voir CLAUDE.md § 2, Paiement de la cotisation. - const result = await api.forms.submitPublic(e, { + const result = await api.forms.submitPublic(e, formToken, { first_name: 'test', last_name: 'smoke', hcaptcha_token: 'dev', @@ -430,7 +432,9 @@ await checkGridLayout().catch((err) => fail('checkGridLayout', err)); renderPublic('/connexion'); renderPublic('/connexion/coordination'); -renderPublic('/inscription'); // formulaire public, hors de +// formulaire public, hors de — jeton fixe du mock (voir +// frontend/src/api/mock/dataset.ts, buildMockState). +renderPublic('/inscription/demo-access-token'); if (coordinator) { setAuthToken(coordinatorToken); diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index f0217f8..77f46e2 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -394,6 +394,14 @@ export function useExportVolunteers() { }); } +/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF pour + * tous les reçus ; même mécanique que `useExportVolunteers`. */ +export function useExportMecenatReceipts() { + return useMutation({ + mutationFn: () => api.volunteers.exportMecenatReceipts(edition), + }); +} + /** Remplace toutes les fenêtres de disponibilité d'un bénévole. */ export function useReplaceAvailability() { const invalidate = useInvalidate(); @@ -645,6 +653,16 @@ export function useUploadFormLogo() { }); } +/** Révoque le lien d'inscription partagé jusqu'ici (nouveau `access_token`) + * — voir `FormTemplateRead.access_token`. */ +export function useRegenerateFormToken() { + const invalidate = useInvalidateFormTemplate(); + return useMutation({ + mutationFn: () => api.forms.regenerateToken(edition), + onSuccess: invalidate, + }); +} + /** Types de paiement de la cotisation (COORDINATION) — voir `FormPaymentOptionRead`. */ export function useFormPaymentOptions() { return useQuery({ @@ -684,19 +702,26 @@ export function useDeleteFormPaymentOption() { } /** URL objet (aperçu) du logo du formulaire public, ou `null` si absent. - * Utilisable sans compte connecté (`GET .../form/logo` n'est pas protégé — - * affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de - * `useFormTemplate`) force un nouveau fetch après un changement de logo. */ -export function useFormLogoUrl(hasLogo: boolean, refreshKey: number | string) { + * `GET .../form/logo` exige `token` (voir `_require_access_token` côté + * back-end) — celui de l'édition pour l'écran de configuration + * (COORDINATION, `template.access_token`), celui de l'URL pour la page + * publique (`/inscription/:token`) ; `null` tant qu'il n'est pas connu. + * `refreshKey` (ex. `dataUpdatedAt` de `useFormTemplate`) force un nouveau + * fetch après un changement de logo. */ +export function useFormLogoUrl( + hasLogo: boolean, + refreshKey: number | string, + token: string | null, +) { const [url, setUrl] = useState(null); useEffect(() => { let objectUrl: string | null = null; let cancelled = false; - if (!hasLogo) { + if (!hasLogo || !token) { setUrl(null); return; } - api.forms.logoBlob(edition).then((blob) => { + api.forms.logoBlob(edition, token).then((blob) => { if (cancelled || !blob) return; objectUrl = URL.createObjectURL(blob); setUrl(objectUrl); @@ -705,22 +730,24 @@ export function useFormLogoUrl(hasLogo: boolean, refreshKey: number | string) { cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; - }, [hasLogo, refreshKey]); + }, [hasLogo, refreshKey, token]); return url; } /** Configuration publique du formulaire — utilisable sans compte connecté - * (écran `/inscription`, hors de `RequireAuth`). */ -export function usePublicFormConfig() { + * (écran `/inscription/:token`, hors de `RequireAuth`). `token` vient de + * l'URL (voir `PublicSignupPage`) ; requis par le back-end en plus de + * l'édition (`_require_access_token`). */ +export function usePublicFormConfig(token: string) { return useQuery({ - queryKey: ['publicFormConfig', edition], - queryFn: () => api.forms.getPublicConfig(edition), + queryKey: ['publicFormConfig', edition, token], + queryFn: () => api.forms.getPublicConfig(edition, token), }); } -export function useSubmitPublicForm() { +export function useSubmitPublicForm(token: string) { return useMutation({ - mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body), + mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, token, body), }); } diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 14b2638..ae1d962 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -157,6 +157,11 @@ export function createHttpApi(baseUrl: string): PlanningApi { if (!blob) throw new ApiError(500, 'error.generic', []); return blob; }, + exportMecenatReceipts: async (editionId) => { + const blob = await requestBlob(`${base(editionId)}/volunteers/mecenat-receipts`); + if (!blob) throw new ApiError(500, 'error.generic', []); + return blob; + }, }, accounts: { list: (editionId) => request(`${base(editionId)}/accounts`), @@ -289,10 +294,15 @@ export function createHttpApi(baseUrl: string): PlanningApi { form.append('file', file); return request(`${base(editionId)}/form/logo`, { method: 'PUT', body: form }); }, - getPublicConfig: (editionId) => request(`${base(editionId)}/form/public`), - logoBlob: (editionId) => requestBlob(`${base(editionId)}/form/logo`), - submitPublic: (editionId, body: PublicFormSubmission) => { + regenerateToken: (editionId) => + request(`${base(editionId)}/form/regenerate-token`, { method: 'POST' }), + getPublicConfig: (editionId, token) => + request(`${base(editionId)}/form/public?token=${encodeURIComponent(token)}`), + logoBlob: (editionId, token) => + requestBlob(`${base(editionId)}/form/logo?token=${encodeURIComponent(token)}`), + submitPublic: (editionId, token, body: PublicFormSubmission) => { const form = new FormData(); + form.append('access_token', token); form.append('first_name', body.first_name); form.append('last_name', body.last_name); form.append('hcaptcha_token', body.hcaptcha_token); diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 77184b7..ec7d1c2 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -91,6 +91,7 @@ export interface MockState { description: string | null; sumupMerchantCode: string | null; sumupApiKey: string | null; + accessToken: string; }; formPaymentOptions: FormPaymentOptionRead[]; /** Inscriptions en attente de paiement en ligne (voir CLAUDE.md § 2, @@ -494,7 +495,7 @@ export function buildMockState(): MockState { }, formTemplate: { isOpen: true, fields: [], logo: null, logoType: null, description: null, - sumupMerchantCode: null, sumupApiKey: null, + sumupMerchantCode: null, sumupApiKey: null, accessToken: 'demo-access-token', }, formPaymentOptions: [], pendingSubmissions: new Map(), diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 0b8297d..5f19569 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -142,6 +142,7 @@ const formTemplateRead = (): FormTemplateRead => ({ description: state.formTemplate.description, sumup_merchant_code: state.formTemplate.sumupMerchantCode, sumup_api_key_set: state.formTemplate.sumupApiKey !== null, + access_token: state.formTemplate.accessToken, }); const sortedPaymentOptions = (): FormPaymentOptionRead[] => @@ -1018,6 +1019,13 @@ export function createMockApi(): PlanningApi { return (await fetch(dataUrl)).blob(); }, exportXlsx: () => delay(MOCK_XLSX_BLOB()), + exportMecenatReceipts: () => { + const paid = state.volunteers.some( + (v) => v.fee_payment_status === 'PAID' && v.mecenat_address !== null, + ); + if (!paid) throw new ApiError(422, 'mecenat.no_receipts'); + return delay(MOCK_PDF_BLOB()); + }, planning: (_edition, id) => { if (!state.volunteers.some((v) => v.id === id)) return notFound(); const entries = state.assignments @@ -1606,11 +1614,17 @@ export function createMockApi(): PlanningApi { state.formTemplate.logoType = file.type; return delay(formTemplateRead()); }, - logoBlob: async () => { + regenerateToken: () => { + state.formTemplate.accessToken = `demo-access-token-${state.seq++}`; + return delay(formTemplateRead()); + }, + logoBlob: async (_edition, token) => { + if (token !== state.formTemplate.accessToken) notFound(); if (!state.formTemplate.logo) return null; return (await fetch(state.formTemplate.logo)).blob(); }, - getPublicConfig: (): Promise => { + getPublicConfig: (_edition, token): Promise => { + if (token !== state.formTemplate.accessToken) notFound(); const fields = state.formTemplate.fields; return delay({ is_open: state.formTemplate.isOpen, @@ -1688,7 +1702,8 @@ export function createMockApi(): PlanningApi { return delay(undefined); }, }, - submitPublic: async (_edition, body: PublicFormSubmission) => { + submitPublic: async (_edition, token, body: PublicFormSubmission) => { + if (token !== state.formTemplate.accessToken) notFound(); if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed'); if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed'); if (!body.first_name.trim() || !body.last_name.trim()) { diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 75272e1..7c63896 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -592,6 +592,9 @@ export interface FormTemplateRead { sumup_merchant_code: string | null; /** `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est défini. */ sumup_api_key_set: boolean; + /** Jeton requis pour atteindre `/inscription/:token` — voir + * CLAUDE.md § 2, `FormTemplate`. Visible ici seulement (COORDINATION). */ + access_token: string; } /** `undefined`/absent = inchangé pour chaque champ. Chaîne vide sur diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 8584e5b..5154a14 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -93,6 +93,9 @@ export interface PlanningApi { photoBlob(editionId: string, id: string): Promise; /** Export Excel de la liste des bénévoles (COORDINATION). */ exportXlsx(editionId: string): Promise; + /** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF + * regroupant tous les reçus (COORDINATION). */ + exportMecenatReceipts(editionId: string): Promise; }; accounts: { /** Comptes de connexion (mot de passe ou lien magique) de l'édition. */ @@ -175,11 +178,19 @@ export interface PlanningApi { setFields(editionId: string, body: FormFieldsSet): Promise; setOpen(editionId: string, body: FormTemplateUpdate): Promise; uploadLogo(editionId: string, file: File): Promise; - /** Publics — aucune authentification, utilisables sans compte connecté - * (y compris `logoBlob`, affiché sur `/inscription`). */ - getPublicConfig(editionId: string): Promise; - submitPublic(editionId: string, body: PublicFormSubmission): Promise; - logoBlob(editionId: string): Promise; + /** Révoque le lien d'inscription partagé jusqu'ici (nouveau + * `access_token`) — l'ancien lien cesse aussitôt de fonctionner. */ + regenerateToken(editionId: string): Promise; + /** Publics — aucune authentification, mais protégés par `token` + * (`FormTemplateRead.access_token`, voir `/inscription/:token`) en plus + * de l'édition : sans lui, aucun de ces appels n'aboutit. */ + getPublicConfig(editionId: string, token: string): Promise; + submitPublic( + editionId: string, + token: string, + body: PublicFormSubmission, + ): Promise; + logoBlob(editionId: string, token: string): Promise; /** Types de paiement de la cotisation (COORDINATION uniquement). */ paymentOptions: { list(editionId: string): Promise; diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 2a8f5c1..81d72d8 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -19,7 +19,7 @@ export const routes: RouteObject[] = [ // Formulaire public d'inscription : aucun compte requis, donc hors de // — voir app/api/forms.py (les seuls endpoints publics de // toute l'API). - { path: '/inscription', element: }, + { path: '/inscription/:token', element: }, { element: , children: [ diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 76c0531..ca09dbb 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -1,6 +1,7 @@ import type { ButtonHTMLAttributes, ReactNode } from 'react'; import { cx } from '@/lib/cx'; +import { useTooltipPosition } from '@/lib/useTooltipPosition'; // Primitives visuelles du design « Bénévoles Festival » : bordures franches, // ombres portées dures, libellés capitales. Aucun texte en dur ici — les @@ -196,20 +197,27 @@ export function HelpTip({ side?: 'top' | 'bottom'; className?: string; }) { + const { anchorRef, tooltipRef, pos, reposition } = useTooltipPosition< + HTMLButtonElement, + HTMLSpanElement + >(side); + return ( {text} diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index dcef771..ff8c041 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -223,6 +223,8 @@ "config.vol.importButton2": "IMPORTER UN FICHIER", "config.vol.exportButton": "EXPORTER EN EXCEL", "config.vol.exporting": "EXPORT…", + "config.vol.mecenatReceiptsButton": "REÇUS MÉCÉNAT (PDF)", + "config.vol.mecenatReceiptsHint": "Génère un PDF regroupant le reçu fiscal (cerfa) de chaque bénévole ayant payé sa cotisation en mécénat, avec ses coordonnées et le montant déjà remplis. Les informations de l'association (encadré du haut) restent à compléter à la main avant remise.", "config.vol.search": "Rechercher un nom, un email…", "config.vol.emptyList": "Aucun bénévole pour l'instant. Ajoutez-en un ou importez un fichier.", "config.vol.noMatch": "Aucun bénévole ne correspond à la recherche.", @@ -391,9 +393,11 @@ "form.field.photo": "Une magnifique photo de toi :", "form.field.comment": "Commentaire", "form.link.title": "Lien du formulaire", - "form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire.", + "form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire, mais lui seul : sans ce lien précis, impossible de tomber sur le formulaire.", "form.link.copy": "Copier", "form.link.copied": "Copié ✓", + "form.link.regenerate": "Régénérer le lien", + "form.link.regenerate.help": "Remplace le lien par un nouveau — utile si l'ancien a fuité. L'ancien lien cesse aussitôt de fonctionner ; il faudra repartager le nouveau.", "form.status.title": "Inscriptions", "form.status.open": "OUVERTES", "form.status.closed": "FERMÉES", @@ -472,5 +476,6 @@ "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.", - "position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes." + "position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes.", + "mecenat.no_receipts": "Aucun bénévole n'a payé la cotisation en mécénat pour cette édition." } diff --git a/frontend/src/lib/useTooltipPosition.ts b/frontend/src/lib/useTooltipPosition.ts new file mode 100644 index 0000000..bdf8913 --- /dev/null +++ b/frontend/src/lib/useTooltipPosition.ts @@ -0,0 +1,51 @@ +import { useRef, useState } from 'react'; + +interface Position { + left: number; + top: number; +} + +/** + * Position (repère viewport, à utiliser avec `position: fixed`) d'une bulle + * ancrée sur un élément déclencheur, recalculée à chaque ouverture et bornée + * à l'intérieur de l'écran (marge `margin`) — un centrage purement CSS + * (`left-1/2 -translate-x-1/2` en position `absolute`) laisse la bulle + * déborder de l'écran quand le déclencheur est près d'un bord (colonne de + * droite, grille défilante…), et la fait passer derrière tout élément + * `position`/`z-index` placé entre elle et la racine (en-têtes figés d'une + * grille défilante, par exemple) : `fixed` s'affranchit des deux à la fois. + * + * `side` n'est qu'une préférence verticale : si la place manque de ce + * côté-là, la bulle bascule automatiquement de l'autre. + */ +export function useTooltipPosition( + side: 'top' | 'bottom' = 'top', + margin = 8, +) { + const anchorRef = useRef(null); + const tooltipRef = useRef(null); + const [pos, setPos] = useState({ left: -9999, top: -9999 }); + + const reposition = () => { + const anchor = anchorRef.current; + const tooltip = tooltipRef.current; + if (!anchor || !tooltip) return; + const anchorRect = anchor.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + + const left = Math.min( + Math.max(anchorRect.left + anchorRect.width / 2 - tooltipRect.width / 2, margin), + window.innerWidth - tooltipRect.width - margin, + ); + + const above = anchorRect.top - tooltipRect.height - 6; + const below = anchorRect.bottom + 6; + let top = side === 'top' ? above : below; + if (side === 'top' && above < margin) top = below; + if (side === 'bottom' && below + tooltipRect.height > window.innerHeight - margin) top = above; + + setPos({ left, top }); + }; + + return { anchorRef, tooltipRef, pos, reposition }; +} diff --git a/frontend/src/pages/FormBuilderPage.tsx b/frontend/src/pages/FormBuilderPage.tsx index 20973fa..f8e9011 100644 --- a/frontend/src/pages/FormBuilderPage.tsx +++ b/frontend/src/pages/FormBuilderPage.tsx @@ -7,6 +7,7 @@ import { useFormLogoUrl, useFormPaymentOptions, useFormTemplate, + useRegenerateFormToken, useSetFormFields, useUpdateFormPaymentOption, useUpdateFormTemplate, @@ -70,7 +71,12 @@ export function FormBuilderPage() { const saveDescription = useUpdateFormTemplate(); const saveSumUp = useUpdateFormTemplate(); const uploadLogo = useUploadFormLogo(); - const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt); + const regenerateToken = useRegenerateFormToken(); + const logoUrl = useFormLogoUrl( + template.data?.has_logo ?? false, + template.dataUpdatedAt, + template.data?.access_token ?? null, + ); const logoInputRef = useRef(null); const [enabled, setEnabled] = useState | null>(null); @@ -102,7 +108,7 @@ export function FormBuilderPage() { }); }; - const link = `${window.location.origin}/inscription`; + const link = `${window.location.origin}/inscription/${template.data?.access_token ?? ''}`; const copyLink = () => { navigator.clipboard.writeText(link).then(() => { setCopied(true); @@ -131,6 +137,22 @@ export function FormBuilderPage() { +

+ regenerateToken.mutate()} + disabled={regenerateToken.isPending} + > + {regenerateToken.isPending ? t('form.saving') : t('form.link.regenerate')} + + + {regenerateToken.isError ? ( + + {errorMessage(regenerateToken.error)} + + ) : null} +
+
{t('form.status.title')} diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx index 5d4bc50..fdd2114 100644 --- a/frontend/src/pages/GrillePage.tsx +++ b/frontend/src/pages/GrillePage.tsx @@ -24,6 +24,7 @@ import { import { hourRangeLabel, shortDayLabel } from '@/lib/time'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; +import { useTooltipPosition } from '@/lib/useTooltipPosition'; export function GrillePage() { const matrix = useMatrix(); @@ -437,22 +438,19 @@ function ShiftCell({ const isInc = !undefinedNeed && !isCrit && !isOver && assigned < shift.ideal_staff; const isOk = !undefinedNeed && !isCrit && !isOver && !isInc; const glyph = undefinedNeed ? '·' : isCrit ? '▲' : isOver ? '▼' : isInc ? '◧' : '✓'; + const { anchorRef, tooltipRef, pos, reposition } = useTooltipPosition< + HTMLDivElement, + HTMLDivElement + >('bottom'); return (
onSelect(shift.shift_id)} + onMouseEnter={reposition} style={{ gridColumn, gridRow }} className={cx( - // `relative z-0` (plutôt que `relative` seul) crée un contexte - // d'empilement local : l'infobulle interne (`z-20`, voir plus bas) - // ne rivalise qu'avec les autres enfants de cette cellule, jamais - // avec les en-têtes figés de la grille (z-10 à 21) — sans ça, un - // survol déclenché par la molette pendant un défilement (le - // curseur reste immobile à l'écran, ce sont les cellules qui - // défilent dessous) pouvait faire passer l'infobulle au-dessus de - // la ligne d'en-tête « Affectés / idéal » pendant sa transition de - // disparition. - 'group relative z-0 flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink', + 'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink', undefinedNeed && 'bg-sand-dark hover:bg-sand', isCrit && 'bg-hatch-orange', isOver && 'bg-hatch-mauve hover:bg-mauve-hover', @@ -463,7 +461,15 @@ function ShiftCell({ {assigned} /{shift.ideal_staff} {glyph} -
+
{hourRangeLabel(shift.starts_at, shift.ends_at)}
diff --git a/frontend/src/pages/PublicSignupPage.tsx b/frontend/src/pages/PublicSignupPage.tsx index dc3e31e..84bdb11 100644 --- a/frontend/src/pages/PublicSignupPage.tsx +++ b/frontend/src/pages/PublicSignupPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; import { ApiError } from '@/api'; import { @@ -140,15 +141,20 @@ export function PublicSignupPage() { // `typeof window` : rendu aussi en SSR (voir scripts/smoke.tsx), où // `window` n'existe pas — contrairement aux autres pages, rien ici ne // dépend d'abord du chargement d'une requête pour en différer l'accès. - const token = useMemo( + const pendingToken = useMemo( () => typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('paiement'), [], ); - if (token) return ; - return ; + // Jeton d'accès au formulaire (`/inscription/:token`, voir CLAUDE.md § 2, + // FormTemplate.access_token) — distinct du jeton de paiement en attente + // ci-dessus, qui n'en a pas besoin (déjà sécurisé par son propre UUID). + const { token: accessToken } = useParams<{ token: string }>(); + if (pendingToken) return ; + if (!accessToken) return null; + return ; } /** Interrogée en direct (voir CLAUDE.md § 2, Paiement de la cotisation) @@ -334,10 +340,10 @@ function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string ); } -function PublicSignupForm() { - const config = usePublicFormConfig(); - const submit = useSubmitPublicForm(); - const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt); +function PublicSignupForm({ accessToken }: { accessToken: string }) { + const config = usePublicFormConfig(accessToken); + const submit = useSubmitPublicForm(accessToken); + const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt, accessToken); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index 08fcad1..5f38dec 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -12,6 +12,7 @@ import { useDeleteVolunteer, useEdition, useExecuteImport, + useExportMecenatReceipts, useExportVolunteers, usePositions, useReplaceAvailability, @@ -489,6 +490,7 @@ export function ConfigVolunteers() { const updateVolunteer = useUpdateVolunteer(); const deleteVolunteer = useDeleteVolunteer(); const exportVolunteers = useExportVolunteers(); + const exportMecenatReceipts = useExportMecenatReceipts(); const [mode, setMode] = useState('list'); const [selectedId, setSelectedId] = useState(null); @@ -535,6 +537,19 @@ export function ConfigVolunteers() { // Erreur exposée via exportVolunteers.isError / .error, affichée plus bas. } }; + const handleExportMecenatReceipts = async () => { + try { + const blob = await exportMecenatReceipts.mutateAsync(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'recus-mecenat.pdf'; + a.click(); + URL.revokeObjectURL(url); + } catch { + // Erreur exposée via exportMecenatReceipts.isError / .error, affichée plus bas. + } + }; if (mode === 'add') { return setMode('list')} />; @@ -577,10 +592,25 @@ export function ConfigVolunteers() { > ↑ {exportVolunteers.isPending ? t('config.vol.exporting') : t('config.vol.exportButton')} + +
{exportVolunteers.isError ? ( ) : null} + {exportMecenatReceipts.isError ? ( + + ) : null}
{total}