Photo du bénévole 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).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
54cba467fc
commit
6892e0307d
@@ -74,7 +74,10 @@ les tables sauf les tables d'association.
|
|||||||
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
||||||
Renseignables aussi via le formulaire public (§ 9) : `photo`/
|
Renseignables aussi via le formulaire public (§ 9) : `photo`/
|
||||||
`photo_content_type` (image, servie séparément par
|
`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
|
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
|
forcément encore en base au moment de l'inscription, donc pas de lien
|
||||||
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
|
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 |
|
| 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 |
|
| 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 |
|
| 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
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
`resolve_owned_ids`/`set_artists`/`set_wished_positions` sont utilisés à la
|
`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`
|
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
|
(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
|
import uuid
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException, UploadFile
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
@@ -77,3 +80,42 @@ def set_wished_positions(
|
|||||||
filter_value=volunteer.id,
|
filter_value=volunteer.id,
|
||||||
pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids],
|
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
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ coordination, faute de savoir si la personne visée est déjà inscrite (voir
|
|||||||
CLAUDE.md § 2, `Volunteer`)."""
|
CLAUDE.md § 2, `Volunteer`)."""
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import io
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
@@ -43,13 +42,15 @@ from fastapi import (
|
|||||||
UploadFile,
|
UploadFile,
|
||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
from PIL import Image, UnidentifiedImageError
|
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.api._common import (
|
from app.api._common import (
|
||||||
|
ALLOWED_PHOTO_TYPES,
|
||||||
|
MAX_PHOTO_BYTES,
|
||||||
owned_or_404,
|
owned_or_404,
|
||||||
|
read_photo,
|
||||||
resolve_owned_ids,
|
resolve_owned_ids,
|
||||||
set_artists,
|
set_artists,
|
||||||
set_wished_positions,
|
set_wished_positions,
|
||||||
@@ -109,8 +110,6 @@ logger = logging.getLogger("app.forms")
|
|||||||
|
|
||||||
router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"])
|
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
|
# 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.
|
# postes « au cas où » — force une vraie priorisation dès le formulaire.
|
||||||
_MAX_POSITION_WISHES = 2
|
_MAX_POSITION_WISHES = 2
|
||||||
@@ -340,14 +339,14 @@ def delete_payment_option(
|
|||||||
|
|
||||||
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
|
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
|
||||||
content_type = file.content_type or ""
|
content_type = file.content_type or ""
|
||||||
if content_type not in _ALLOWED_PHOTO_TYPES:
|
if content_type not in ALLOWED_PHOTO_TYPES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422, detail={"code": "form.logo_type_not_supported"}
|
status_code=422, detail={"code": "form.logo_type_not_supported"}
|
||||||
)
|
)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
if not content:
|
if not content:
|
||||||
raise HTTPException(status_code=400, detail={"code": "form.logo_empty"})
|
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"})
|
raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"})
|
||||||
return content, content_type
|
return content, content_type
|
||||||
|
|
||||||
@@ -462,39 +461,6 @@ def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
|
|||||||
session.commit()
|
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(
|
def _filter_owned_ids(
|
||||||
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID]
|
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID]
|
||||||
) -> 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
|
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:
|
if payment_option is None:
|
||||||
# Pas de paiement en ligne (case désactivée, ou « je paierai sur
|
# Pas de paiement en ligne (case désactivée, ou « je paierai sur
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
|
|||||||
|
|
||||||
import uuid
|
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 sqlalchemy.orm import aliased
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.api._common import (
|
from app.api._common import (
|
||||||
owned_or_404,
|
owned_or_404,
|
||||||
|
read_photo,
|
||||||
replace_links,
|
replace_links,
|
||||||
resolve_owned_ids,
|
resolve_owned_ids,
|
||||||
set_artists,
|
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)
|
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
|
||||||
def update_volunteer(
|
def update_volunteer(
|
||||||
volunteer_id: uuid.UUID,
|
volunteer_id: uuid.UUID,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
|
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
|
||||||
"form.invalid_payload": "Données du formulaire invalides.",
|
"form.invalid_payload": "Données du formulaire invalides.",
|
||||||
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
"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.photo_too_large": "La photo est trop volumineuse.",
|
||||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||||
"form.logo_empty": "Le logo est vide.",
|
"form.logo_empty": "Le logo est vide.",
|
||||||
|
|||||||
@@ -609,3 +609,50 @@ def test_export_gives_the_sumup_transaction(client, edition, session):
|
|||||||
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
|
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
|
||||||
# L'identifiant du checkout, lui, reste interne.
|
# L'identifiant du checkout, lui, reste interne.
|
||||||
assert "fee_checkout_id" not in fiche
|
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
|
||||||
|
|||||||
@@ -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
|
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public ou
|
||||||
* `null` si absente. */
|
* ajoutée par la coordination), ou `null` si absente. `version` (ex.
|
||||||
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) {
|
* `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);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let objectUrl: string | null = null;
|
let objectUrl: string | null = null;
|
||||||
@@ -123,7 +129,7 @@ export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, has
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
};
|
};
|
||||||
}, [volunteerId, hasPhoto]);
|
}, [volunteerId, hasPhoto, version]);
|
||||||
return url;
|
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() {
|
export function useDeleteVolunteer() {
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
planning: (editionId, id) =>
|
planning: (editionId, id) =>
|
||||||
request(`${base(editionId)}/volunteers/${id}/planning`),
|
request(`${base(editionId)}/volunteers/${id}/planning`),
|
||||||
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
|
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) => {
|
exportXlsx: async (editionId) => {
|
||||||
const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
|
const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
|
||||||
if (!blob) throw new ApiError(500, 'error.generic', []);
|
if (!blob) throw new ApiError(500, 'error.generic', []);
|
||||||
|
|||||||
@@ -1069,6 +1069,21 @@ export function createMockApi(): PlanningApi {
|
|||||||
if (!dataUrl) return null;
|
if (!dataUrl) return null;
|
||||||
return (await fetch(dataUrl)).blob();
|
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()),
|
exportXlsx: () => delay(MOCK_XLSX_BLOB()),
|
||||||
exportMecenatReceipts: () => {
|
exportMecenatReceipts: () => {
|
||||||
const paid = state.volunteers.some(
|
const paid = state.volunteers.some(
|
||||||
|
|||||||
@@ -88,8 +88,13 @@ export interface PlanningApi {
|
|||||||
remove(editionId: string, id: string): Promise<void>;
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
|
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
|
||||||
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
|
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>;
|
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). */
|
/** Export Excel de la liste des bénévoles (COORDINATION). */
|
||||||
exportXlsx(editionId: string): Promise<Blob>;
|
exportXlsx(editionId: string): Promise<Blob>;
|
||||||
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF
|
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF
|
||||||
|
|||||||
@@ -232,6 +232,13 @@
|
|||||||
"config.vol.field.comment": "Commentaire",
|
"config.vol.field.comment": "Commentaire",
|
||||||
"config.vol.field.badgeText": "Texte du badge",
|
"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.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.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.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",
|
"config.vol.access.grant": "Donner un accès de connexion",
|
||||||
@@ -356,7 +363,7 @@
|
|||||||
"badges.field.role.placeholder": "Référent",
|
"badges.field.role.placeholder": "Référent",
|
||||||
"badges.field.photo": "Photo du bénévole",
|
"badges.field.photo": "Photo du bénévole",
|
||||||
"badges.field.photo.placeholder": "Photo",
|
"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": "Texte libre",
|
||||||
"badges.field.freeText.placeholder": "Accès backstage",
|
"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é.",
|
"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.captcha_failed": "Vérification anti-robot invalide, réessaie.",
|
||||||
"form.invalid_payload": "Données du formulaire invalides.",
|
"form.invalid_payload": "Données du formulaire invalides.",
|
||||||
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
"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.photo_too_large": "La photo est trop volumineuse.",
|
||||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||||
"form.logo_empty": "Le logo est vide.",
|
"form.logo_empty": "Le logo est vide.",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
useExportMecenatReceipts,
|
useExportMecenatReceipts,
|
||||||
useExportVolunteers,
|
useExportVolunteers,
|
||||||
usePositions,
|
usePositions,
|
||||||
|
useRemoveVolunteerPhoto,
|
||||||
|
useSetVolunteerPhoto,
|
||||||
useUpdateAccount,
|
useUpdateAccount,
|
||||||
useUpdatePosition,
|
useUpdatePosition,
|
||||||
useUpdateVolunteer,
|
useUpdateVolunteer,
|
||||||
@@ -167,11 +169,87 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
|
|||||||
EXPIRED: 'config.vol.feeStatus.expired',
|
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`) —
|
* 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
|
* 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),
|
* 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).
|
* effectivement de ce formulaire (sinon rien à afficher).
|
||||||
*/
|
*/
|
||||||
function PublicFormAnswers({
|
function PublicFormAnswers({
|
||||||
@@ -181,12 +259,8 @@ function PublicFormAnswers({
|
|||||||
volunteer: VolunteerRead;
|
volunteer: VolunteerRead;
|
||||||
patch: (body: VolunteerUpdate) => void;
|
patch: (body: VolunteerUpdate) => void;
|
||||||
}) {
|
}) {
|
||||||
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo);
|
|
||||||
|
|
||||||
const hasAnything =
|
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;
|
if (!hasAnything) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -195,14 +269,6 @@ function PublicFormAnswers({
|
|||||||
{t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} />
|
{t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} />
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
|
|
||||||
{photoUrl ? (
|
|
||||||
<img
|
|
||||||
src={photoUrl}
|
|
||||||
alt=""
|
|
||||||
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{volunteer.companion_wish_note ? (
|
{volunteer.companion_wish_note ? (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
|
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
|
||||||
@@ -894,6 +960,7 @@ export function ConfigVolunteers() {
|
|||||||
onChange={(v) => patch({ badge_text: v || null })}
|
onChange={(v) => patch({ badge_text: v || null })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
|
||||||
|
|
||||||
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||||
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
|
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
|
||||||
|
|||||||
Reference in New Issue
Block a user