diff --git a/CLAUDE.md b/CLAUDE.md index 52c7e30..5d32e23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + diff --git a/backend/app/api/_common.py b/backend/app/api/_common.py index aefb678..3bb4d17 100644 --- a/backend/app/api/_common.py +++ b/backend/app/api/_common.py @@ -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 diff --git a/backend/app/api/forms.py b/backend/app/api/forms.py index bdec908..4ae4e14 100644 --- a/backend/app/api/forms.py +++ b/backend/app/api/forms.py @@ -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 diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index 14a40d9..defb447 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -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, diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 3e1d180..b5944c8 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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.", diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py index 60f14b8..b7da5c6 100644 --- a/backend/tests/test_api_volunteers.py +++ b/backend/tests/test_api_volunteers.py @@ -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 diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 7bb692f..97d1fe1 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -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(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({ diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index bc93497..3128c21 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -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', []); diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 81b75b4..3ee0098 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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( diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index fdb6b61..541d768 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -88,8 +88,13 @@ export interface PlanningApi { remove(editionId: string, id: string): Promise; /** Planning enrichi (poste, lieu, référent) — écran bénévole. */ planning(editionId: string, id: string): Promise; - /** 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; + /** Ajoute ou remplace la photo (COORDINATION) — PNG/JPEG. */ + setPhoto(editionId: string, id: string, file: File): Promise; + /** Retire la photo (COORDINATION). */ + removePhoto(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 diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 32d2efe..cb4f77b 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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.", diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index c2f0ecd..98ed49a 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -14,6 +14,8 @@ import { useExportMecenatReceipts, useExportVolunteers, usePositions, + useRemoveVolunteerPhoto, + useSetVolunteerPhoto, useUpdateAccount, useUpdatePosition, useUpdateVolunteer, @@ -167,11 +169,87 @@ const FEE_STATUS_LABEL_KEY: Record(null); + const pending = setPhoto.isPending || removePhoto.isPending; + + return ( +
+ + {t('config.vol.photo.title')} + +
+ {photoUrl ? ( + + ) : ( + + {t('config.vol.photo.none')} + + )} +
+ + {volunteer.has_photo ? ( + + ) : null} +
+ { + const file = e.target.files?.[0]; + if (file) setPhoto.mutate({ id: volunteer.id, file }); + e.target.value = ''; + }} + /> +
+ {setPhoto.isError ? : null} + {removePhoto.isError ? : null} +
+ ); +} + /** * 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')} - {photoUrl ? ( - - ) : null} - {volunteer.companion_wish_note ? ( +