Photo du bénévole ajoutée par la coordination
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 14s

Section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer),
PUT/DELETE /editions/{id}/volunteers/{vid}/photo (COORDINATION),
validation de l'image partagée avec le formulaire public (read_photo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 18:17:25 +01:00
co-authored by Claude Opus 5.5
parent 54cba467fc
commit 6892e0307d
12 changed files with 288 additions and 64 deletions
+5 -1
View File
@@ -74,7 +74,10 @@ les tables sauf les tables d'association.
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
Renseignables aussi via le formulaire public (§ 9) : `photo`/
`photo_content_type` (image, servie séparément par
`GET .../volunteers/{id}/photo`, COORD/LEAD), `companion_wish_note` (« je
`GET .../volunteers/{id}/photo`, COORD/LEAD — la coordination peut aussi
l'ajouter, la remplacer ou la retirer depuis la fiche, section « Photo » :
`PUT`/`DELETE .../volunteers/{id}/photo`, COORDINATION, même validation
que le formulaire, `app/api/_common.py:read_photo`), `companion_wish_note` (« je
voudrais être avec » en texte libre — la personne visée n'existe pas
forcément encore en base au moment de l'inscription, donc pas de lien
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
@@ -929,6 +932,7 @@ Aucune dépendance locale requise hors Docker.
| 44 | Un bénévole peut être affecté à deux postes en même temps : `SHIFT_OVERLAP` passe de `BLOCKING` à `WARNING` (affectation enregistrée, avertissement affiché) ; le solveur ne l'exclut plus mais lui applique un fort malus (`_ALREADY_BUSY_PENALTY`) — proposé seulement pour un créneau sous son minimum, faute de mieux — repris dans le classement par défaut du panneau et dans le mock | à valider |
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider |
| 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+44 -2
View File
@@ -3,11 +3,14 @@
`resolve_owned_ids`/`set_artists`/`set_wished_positions` sont utilisés à la
fois par `volunteers.py` (fiche éditée par la coordination) et `forms.py`
(vœux saisis depuis le formulaire public) — un seul endroit pour la
validation « ces ids appartiennent bien à cette édition »."""
validation « ces ids appartiennent bien à cette édition ». Idem pour
`read_photo` (photo d'un bénévole)."""
import io
import uuid
from fastapi import HTTPException
from fastapi import HTTPException, UploadFile
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlmodel import Session, select
@@ -77,3 +80,42 @@ def set_wished_positions(
filter_value=volunteer.id,
pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids],
)
MAX_PHOTO_BYTES = 8 * 1024 * 1024
ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
async def read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
"""Validation partagée par le formulaire public (inscription immédiate
ou en attente de paiement) et l'ajout d'une photo par la coordination
depuis la fiche (`volunteers.py:set_volunteer_photo`) — `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 ""
if content_type not in ALLOWED_PHOTO_TYPES:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
)
content = await photo.read()
if len(content) > MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
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
+6 -40
View File
@@ -25,7 +25,6 @@ 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
@@ -43,13 +42,15 @@ from fastapi import (
UploadFile,
status,
)
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.api._common import (
ALLOWED_PHOTO_TYPES,
MAX_PHOTO_BYTES,
owned_or_404,
read_photo,
resolve_owned_ids,
set_artists,
set_wished_positions,
@@ -109,8 +110,6 @@ logger = logging.getLogger("app.forms")
router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"])
_MAX_PHOTO_BYTES = 8 * 1024 * 1024
_ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
# Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les
# postes « au cas où » — force une vraie priorisation dès le formulaire.
_MAX_POSITION_WISHES = 2
@@ -340,14 +339,14 @@ def delete_payment_option(
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
content_type = file.content_type or ""
if content_type not in _ALLOWED_PHOTO_TYPES:
if content_type not in ALLOWED_PHOTO_TYPES:
raise HTTPException(
status_code=422, detail={"code": "form.logo_type_not_supported"}
)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail={"code": "form.logo_empty"})
if len(content) > _MAX_PHOTO_BYTES:
if len(content) > MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"})
return content, content_type
@@ -462,39 +461,6 @@ def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
session.commit()
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. `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 ""
if content_type not in _ALLOWED_PHOTO_TYPES:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
)
content = await photo.read()
if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
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(
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID]
) -> list[uuid.UUID]:
@@ -688,7 +654,7 @@ async def submit_public_form(
else payment_option.amount if payment_option is not None else None
)
photo_data = await _read_photo(photo) if FormFieldType.PHOTO in fields else None
photo_data = await read_photo(photo) if FormFieldType.PHOTO in fields else None
if payment_option is None:
# Pas de paiement en ligne (case désactivée, ou « je paierai sur
+40 -1
View File
@@ -5,12 +5,13 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from sqlalchemy.orm import aliased
from sqlmodel import Session, select
from app.api._common import (
owned_or_404,
read_photo,
replace_links,
resolve_owned_ids,
set_artists,
@@ -342,6 +343,44 @@ def get_volunteer_photo(
)
@router.put("/{volunteer_id}/photo", response_model=VolunteerRead)
async def set_volunteer_photo(
volunteer_id: uuid.UUID,
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
"""Ajout (ou remplacement) de la photo par la coordination, depuis la
fiche — pour un bénévole qui n'en a pas envoyé au formulaire public ou
ajouté à la main. Même validation que le formulaire (`read_photo`)."""
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
photo = await read_photo(file)
if photo is None:
raise HTTPException(status_code=400, detail={"code": "volunteer.empty_photo"})
volunteer.photo, volunteer.photo_content_type = photo
session.add(volunteer)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.delete("/{volunteer_id}/photo", response_model=VolunteerRead)
def delete_volunteer_photo(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
volunteer.photo = None
volunteer.photo_content_type = None
session.add(volunteer)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
def update_volunteer(
volunteer_id: uuid.UUID,
+1
View File
@@ -78,6 +78,7 @@
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
"form.invalid_payload": "Données du formulaire invalides.",
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
"volunteer.empty_photo": "Le fichier de la photo est vide.",
"form.photo_too_large": "La photo est trop volumineuse.",
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
"form.logo_empty": "Le logo est vide.",
+47
View File
@@ -609,3 +609,50 @@ def test_export_gives_the_sumup_transaction(client, edition, session):
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
# L'identifiant du checkout, lui, reste interne.
assert "fee_checkout_id" not in fiche
def _png() -> bytes:
from PIL import Image
out = io.BytesIO()
Image.new("RGB", (4, 4), "red").save(out, format="PNG")
return out.getvalue()
def test_coordinator_adds_then_removes_a_volunteer_photo(client, edition, session):
volunteer = make_volunteer(session, edition)
url = _url(edition, f"/{volunteer.id}/photo")
assert client.get(url).status_code == 404
resp = client.put(url, files={"file": ("photo.png", _png(), "image/png")})
assert resp.status_code == 200
assert resp.json()["has_photo"] is True
photo = client.get(url)
assert photo.status_code == 200
assert photo.headers["content-type"] == "image/png"
resp = client.delete(url)
assert resp.status_code == 200
assert resp.json()["has_photo"] is False
assert client.get(url).status_code == 404
def test_adding_a_fake_photo_is_rejected(client, edition, session):
volunteer = make_volunteer(session, edition)
resp = client.put(
_url(edition, f"/{volunteer.id}/photo"),
files={"file": ("photo.png", b"pas une image", "image/png")},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
def test_only_the_coordination_can_change_a_photo(
session, edition, make_account, as_account
):
volunteer = make_volunteer(session, edition)
lead = as_account(make_account(Role.LEAD, email="lead@e.fr"))
url = _url(edition, f"/{volunteer.id}/photo")
assert lead.put(url, files={"file": ("p.png", _png(), "image/png")}).status_code == 403
assert lead.delete(url).status_code == 403
+27 -4
View File
@@ -103,9 +103,15 @@ export function useVolunteerPlanning(volunteerId: string | null | undefined) {
});
}
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public), ou
* `null` si absente. */
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) {
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public ou
* ajoutée par la coordination), ou `null` si absente. `version` (ex.
* `updated_at`) force le rechargement quand la photo est remplacée —
* `hasPhoto` resterait `true` et rien ne changerait sinon. */
export function useVolunteerPhotoUrl(
volunteerId: string | null | undefined,
hasPhoto: boolean,
version?: string,
) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
@@ -123,7 +129,7 @@ export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, has
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [volunteerId, hasPhoto]);
}, [volunteerId, hasPhoto, version]);
return url;
}
@@ -361,6 +367,23 @@ export function useUpdateVolunteer() {
});
}
export function useSetVolunteerPhoto() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) =>
api.volunteers.setPhoto(edition, id, file),
onSuccess: invalidate.volunteers,
});
}
export function useRemoveVolunteerPhoto() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.volunteers.removePhoto(edition, id),
onSuccess: invalidate.volunteers,
});
}
export function useDeleteVolunteer() {
const invalidate = useInvalidate();
return useMutation({
+7
View File
@@ -155,6 +155,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
planning: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/planning`),
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
setPhoto: (editionId, id, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'PUT', body: form });
},
removePhoto: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'DELETE' }),
exportXlsx: async (editionId) => {
const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
if (!blob) throw new ApiError(500, 'error.generic', []);
+15
View File
@@ -1069,6 +1069,21 @@ export function createMockApi(): PlanningApi {
if (!dataUrl) return null;
return (await fetch(dataUrl)).blob();
},
setPhoto: async (_edition, id, file) => {
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
if (!['image/png', 'image/jpeg'].includes(file.type)) {
throw new ApiError(422, 'form.photo_type_not_supported');
}
state.photos.set(id, await fileToDataUrl(file));
Object.assign(v, { has_photo: true, updated_at: touch() });
return delay(v);
},
removePhoto: (_edition, id) => {
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
state.photos.delete(id);
Object.assign(v, { has_photo: false, updated_at: touch() });
return delay(v);
},
exportXlsx: () => delay(MOCK_XLSX_BLOB()),
exportMecenatReceipts: () => {
const paid = state.volunteers.some(
+6 -1
View File
@@ -88,8 +88,13 @@ export interface PlanningApi {
remove(editionId: string, id: string): Promise<void>;
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
/** Photo transmise via le formulaire public, `null` si absente. */
/** Photo transmise via le formulaire public (ou ajoutée par la
* coordination), `null` si absente. */
photoBlob(editionId: string, id: string): Promise<Blob | null>;
/** Ajoute ou remplace la photo (COORDINATION) — PNG/JPEG. */
setPhoto(editionId: string, id: string, file: File): Promise<VolunteerRead>;
/** Retire la photo (COORDINATION). */
removePhoto(editionId: string, id: string): Promise<VolunteerRead>;
/** Export Excel de la liste des bénévoles (COORDINATION). */
exportXlsx(editionId: string): Promise<Blob>;
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF
+9 -1
View File
@@ -232,6 +232,13 @@
"config.vol.field.comment": "Commentaire",
"config.vol.field.badgeText": "Texte du badge",
"config.vol.field.badgeText.help": "Imprimé sur le badge si le champ « Texte libre » y est placé (écran Badges).",
"config.vol.photo.title": "Photo",
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.",
"config.vol.photo.none": "Aucune photo",
"config.vol.photo.add": "Ajouter une photo",
"config.vol.photo.replace": "Remplacer la photo",
"config.vol.photo.remove": "Retirer",
"config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?",
"config.vol.access.title": "Accès de connexion",
"config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.",
"config.vol.access.grant": "Donner un accès de connexion",
@@ -356,7 +363,7 @@
"badges.field.role.placeholder": "Référent",
"badges.field.photo": "Photo du bénévole",
"badges.field.photo.placeholder": "Photo",
"badges.field.photo.help": "La photo envoyée avec le formulaire d'inscription, recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
"badges.field.freeText": "Texte libre",
"badges.field.freeText.placeholder": "Accès backstage",
"badges.field.freeText.help": "Texte propre à chaque bénévole, à saisir sur sa fiche (Configuration › Bénévoles, « Texte du badge »). Vide = rien d'imprimé.",
@@ -543,6 +550,7 @@
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
"form.invalid_payload": "Données du formulaire invalides.",
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
"volunteer.empty_photo": "Le fichier de la photo est vide.",
"form.photo_too_large": "La photo est trop volumineuse.",
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
"form.logo_empty": "Le logo est vide.",
+81 -14
View File
@@ -14,6 +14,8 @@ import {
useExportMecenatReceipts,
useExportVolunteers,
usePositions,
useRemoveVolunteerPhoto,
useSetVolunteerPhoto,
useUpdateAccount,
useUpdatePosition,
useUpdateVolunteer,
@@ -167,11 +169,87 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
EXPIRED: 'config.vol.feeStatus.expired',
};
/**
* Photo du bénévole (badge, écrans bénévole/référent, feuilles de poste) :
* celle envoyée au formulaire public, ou ajoutée ici par la coordination
* pour un bénévole qui n'en a pas donné (ajouté à la main, importé…).
*/
function VolunteerPhotoSection({ volunteer }: { volunteer: VolunteerRead }) {
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo, volunteer.updated_at);
const setPhoto = useSetVolunteerPhoto();
const removePhoto = useRemoveVolunteerPhoto();
const inputRef = useRef<HTMLInputElement>(null);
const pending = setPhoto.isPending || removePhoto.isPending;
return (
<div>
<FieldLabel>
{t('config.vol.photo.title')} <HelpTip text={t('config.vol.photo.hint')} />
</FieldLabel>
<div className="flex items-center gap-3">
{photoUrl ? (
<img
src={photoUrl}
alt=""
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
/>
) : (
<span className="flex h-[90px] w-[90px] flex-none items-center justify-center border-2 border-dashed border-ink/50 bg-cream font-mono text-[9.5px] opacity-60">
{t('config.vol.photo.none')}
</span>
)}
<div className="flex flex-col items-start gap-2">
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[7px] font-display text-[10.5px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:cursor-not-allowed disabled:opacity-50"
>
{setPhoto.isPending
? t('app.loading')
: volunteer.has_photo
? t('config.vol.photo.replace')
: t('config.vol.photo.add')}
</button>
{volunteer.has_photo ? (
<button
type="button"
disabled={pending}
onClick={() => {
if (window.confirm(t('config.vol.photo.removeConfirm'))) {
removePhoto.mutate(volunteer.id);
}
}}
className="cursor-pointer border-2 border-ink bg-paper px-[8px] py-[5px] font-display text-[9.5px] font-extrabold uppercase tracking-wide hover:bg-orange disabled:opacity-50"
>
{t('config.vol.photo.remove')}
</button>
) : null}
</div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setPhoto.mutate({ id: volunteer.id, file });
e.target.value = '';
}}
/>
</div>
{setPhoto.isError ? <ErrorNote error={setPhoto.error} /> : null}
{removePhoto.isError ? <ErrorNote error={removePhoto.error} /> : null}
</div>
);
}
/**
* Réponses du formulaire public d'inscription (voir `app/api/forms.py`) —
* note de compagnon en texte libre (à résoudre manuellement en vrai vœu via
* le sélecteur ci-dessus une fois la personne visée elle-même inscrite),
* cases informatives, photo. N'apparaît que si le bénévole vient
* cases informatives (la photo a sa propre section, `VolunteerPhotoSection`).
* N'apparaît que si le bénévole vient
* effectivement de ce formulaire (sinon rien à afficher).
*/
function PublicFormAnswers({
@@ -181,12 +259,8 @@ function PublicFormAnswers({
volunteer: VolunteerRead;
patch: (body: VolunteerUpdate) => void;
}) {
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo);
const hasAnything =
volunteer.has_photo ||
Boolean(volunteer.companion_wish_note) ||
volunteer.self_declared_available;
Boolean(volunteer.companion_wish_note) || volunteer.self_declared_available;
if (!hasAnything) return null;
return (
@@ -195,14 +269,6 @@ function PublicFormAnswers({
{t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} />
</FieldLabel>
{photoUrl ? (
<img
src={photoUrl}
alt=""
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
/>
) : null}
{volunteer.companion_wish_note ? (
<label className="block">
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
@@ -894,6 +960,7 @@ export function ConfigVolunteers() {
onChange={(v) => patch({ badge_text: v || null })}
/>
</label>
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />