Feuilles de poste : ajouter la photo des bénévoles affectés

Vignette carrée (14 mm, proportions préservées) à côté du nom de chaque
bénévole affecté, à côté de son téléphone — case vide si le bénévole n'a
pas de photo. Complète l'affichage déjà en place sur l'écran référent et
l'écran bénévole (étape 22).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-22 20:06:51 +02:00
co-authored by Claude Sonnet 5
parent 15109acfe5
commit f6fbfaf82a
5 changed files with 88 additions and 8 deletions
+7 -4
View File
@@ -188,7 +188,9 @@ les tables sauf les tables d'association.
bénévole ; une feuille identifie un poste pour la journée) : une page par bénévole ; une feuille identifie un poste pour la journée) : une page par
poste et par jour, avec son référent (`Position.lead_account_id`, nom + poste et par jour, avec son référent (`Position.lead_account_id`, nom +
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour
avec les bénévoles affectés et leur téléphone, pour affichage sur place. avec les bénévoles affectés, leur téléphone et leur photo (`Volunteer.photo`
— vignette carrée 14 mm, proportions préservées, case vide si le bénévole
n'en a pas), pour affichage sur place.
`POST /editions/{id}/position-sheets/generate` (COORDINATION, `POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune `{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer). affectation n'apparaît pas (rien à imprimer).
@@ -221,7 +223,7 @@ Conventions techniques :
type ENUM natif PostgreSQL. type ENUM natif PostgreSQL.
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les - Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`, `edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
`artist`, `account`. La suppression complète d'un bénévole (RGPD) efface `artist`, `account`. La suppression complète d'un bénévole efface
donc ses `Availability`, `Assignment`, vœux (artistes, postes, compagnons — donc ses `Availability`, `Assignment`, vœux (artistes, postes, compagnons —
dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son
`Account`. `Account`.
@@ -305,7 +307,7 @@ référent d'un poste (voir `Position.lead_account_id` plus haut).
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒ Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire
ni corps** — RGPD). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`, ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
`PUBLIC_BASE_URL` (pour les liens). Ne jamais mettre de vraies valeurs dans `PUBLIC_BASE_URL` (pour les liens). Ne jamais mettre de vraies valeurs dans
`config.py` : elles vivent dans `.env` (non versionné). `config.py` : elles vivent dans `.env` (non versionné).
@@ -505,7 +507,7 @@ l'étape 21.)*
│ │ ├── main.py # instance FastAPI │ │ ├── main.py # instance FastAPI
│ │ ├── config.py # settings (pydantic-settings) │ │ ├── config.py # settings (pydantic-settings)
│ │ ├── db.py # engine + session │ │ ├── db.py # engine + session
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs │ │ ├── logging_config.py # pas de PII dans les logs
│ │ ├── api/ # routers + schemas Pydantic + deps │ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, editions, │ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers, │ │ │ # day_parts, imports, artists, volunteers,
@@ -634,6 +636,7 @@ Aucune dépendance locale requise hors Docker.
| 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider | | 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider |
| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider | | 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider |
| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider | | 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider |
| 24 | Feuilles de poste : photo des bénévoles affectés à côté de leur nom (vignette 14 mm, case vide si absente) | à 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 -1
View File
@@ -106,7 +106,9 @@ def generate_position_sheets(
min_staff=s.min_staff, min_staff=s.min_staff,
ideal_staff=s.ideal_staff, ideal_staff=s.ideal_staff,
volunteers=tuple( volunteers=tuple(
SheetVolunteer(name=f"{v.first_name} {v.last_name}", phone=v.phone) SheetVolunteer(
name=f"{v.first_name} {v.last_name}", phone=v.phone, photo=v.photo
)
for v in volunteers_by_shift.get(s.id, []) for v in volunteers_by_shift.get(s.id, [])
), ),
) )
+17 -3
View File
@@ -17,7 +17,9 @@ from reportlab.lib import colors
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.platypus import ( from reportlab.platypus import (
Image,
PageBreak, PageBreak,
Paragraph, Paragraph,
SimpleDocTemplate, SimpleDocTemplate,
@@ -26,6 +28,8 @@ from reportlab.platypus import (
TableStyle, TableStyle,
) )
_PHOTO_BOX_MM = 14 * mm
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"] _WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_MONTHS_FR = [ _MONTHS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin", "janvier", "février", "mars", "avril", "mai", "juin",
@@ -41,6 +45,7 @@ def _format_day_fr(date: dt.date) -> str:
class SheetVolunteer: class SheetVolunteer:
name: str name: str
phone: str | None phone: str | None
photo: bytes | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -95,11 +100,19 @@ def _styles() -> dict[str, ParagraphStyle]:
} }
def _photo_thumbnail(photo: bytes) -> Image:
"""Vignette carrée (14 mm de côté max), proportions préservées."""
width_px, height_px = ImageReader(io.BytesIO(photo)).getSize()
scale = _PHOTO_BOX_MM / max(width_px, height_px)
return Image(io.BytesIO(photo), width=width_px * scale, height=height_px * scale)
def _shift_table(shift: SheetShift) -> Table: def _shift_table(shift: SheetShift) -> Table:
rows = [["Bénévole", "Téléphone"]] + [ rows = [["", "Bénévole", "Téléphone"]] + [
[v.name, v.phone or "—"] for v in shift.volunteers [_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"]
for v in shift.volunteers
] ]
table = Table(rows, colWidths=[110 * mm, 55 * mm]) table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm])
table.setStyle( table.setStyle(
TableStyle( TableStyle(
[ [
@@ -107,6 +120,7 @@ def _shift_table(shift: SheetShift) -> Table:
("FONTSIZE", (0, 0), (-1, -1), 10.5), ("FONTSIZE", (0, 0), (-1, -1), 10.5),
("TOPPADDING", (0, 0), (-1, -1), 5), ("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black), ("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black),
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey), ("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey),
] ]
+31
View File
@@ -1,8 +1,16 @@
"""Feuilles de poste (PDF) — API.""" """Feuilles de poste (PDF) — API."""
import base64
from app.models import Assignment, Role from app.models import Assignment, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer from tests.factories import dt_at, make_position, make_shift, make_volunteer
# PNG 1×1 valide (transparent), le plus petit possible.
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
"42YAAAAASUVORK5CYII="
)
def _url(edition): def _url(edition):
return f"/editions/{edition.id}/position-sheets/generate" return f"/editions/{edition.id}/position-sheets/generate"
@@ -70,6 +78,29 @@ def test_generates_a_pdf_with_a_referent(client, edition, session, make_account)
assert resp.content.startswith(b"%PDF") assert resp.content.startswith(b"%PDF")
def test_includes_a_volunteer_photo_when_present(client, edition, session):
position = make_position(session, edition, name="Bar central")
shift = make_shift(
session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22),
min_staff=1, ideal_staff=2,
)
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
with_photo.photo = _PNG
with_photo.photo_content_type = "image/png"
without_photo = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
session.add_all(
[
Assignment(edition_id=edition.id, volunteer_id=with_photo.id, shift_id=shift.id),
Assignment(edition_id=edition.id, volunteer_id=without_photo.id, shift_id=shift.id),
]
)
session.commit()
resp = client.post(_url(edition), json={})
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF")
def test_position_ids_filter_scopes_the_sheets(client, edition, session): def test_position_ids_filter_scopes_the_sheets(client, edition, session):
bar = make_position(session, edition, name="Bar central") bar = make_position(session, edition, name="Bar central")
welcome = make_position(session, edition, name="Accueil") welcome = make_position(session, edition, name="Accueil")
+30
View File
@@ -1,5 +1,6 @@
"""Génération des feuilles de poste (module pur, sans base).""" """Génération des feuilles de poste (module pur, sans base)."""
import base64
import datetime as dt import datetime as dt
from app.sheets.pdf import ( from app.sheets.pdf import (
@@ -9,6 +10,12 @@ from app.sheets.pdf import (
generate_position_sheets_pdf, generate_position_sheets_pdf,
) )
# PNG 1×1 valide (transparent), le plus petit possible.
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
"42YAAAAASUVORK5CYII="
)
def _sheet(volunteers=(), min_staff=1, ideal_staff=2) -> PositionDaySheet: def _sheet(volunteers=(), min_staff=1, ideal_staff=2) -> PositionDaySheet:
return PositionDaySheet( return PositionDaySheet(
@@ -88,3 +95,26 @@ def test_generates_a_valid_pdf_without_a_referent():
assert sheet.referent_name is None assert sheet.referent_name is None
pdf = generate_position_sheets_pdf([sheet]) pdf = generate_position_sheets_pdf([sheet])
assert pdf.startswith(b"%PDF") assert pdf.startswith(b"%PDF")
def test_generates_a_valid_pdf_with_volunteer_photos():
sheet = _sheet(
volunteers=(
SheetVolunteer(name="Alex Martin", phone="0600000000", photo=_PNG),
SheetVolunteer(name="Sam Nuit", phone=None, photo=None),
)
)
assert sheet.shifts[0].volunteers[0].photo == _PNG
assert sheet.shifts[0].volunteers[1].photo is None
pdf = generate_position_sheets_pdf([sheet])
assert pdf.startswith(b"%PDF")
def test_a_volunteer_photo_makes_the_pdf_larger():
without_photo = generate_position_sheets_pdf(
[_sheet(volunteers=(SheetVolunteer(name="Alex Martin", phone=None, photo=None),))]
)
with_photo = generate_position_sheets_pdf(
[_sheet(volunteers=(SheetVolunteer(name="Alex Martin", phone=None, photo=_PNG),))]
)
assert len(with_photo) > len(without_photo)