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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user