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
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
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,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer).
@@ -221,7 +223,7 @@ Conventions techniques :
type ENUM natif PostgreSQL.
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
`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 —
dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son
`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) ⇒
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
`config.py` : elles vivent dans `.env` (non versionné).
@@ -505,7 +507,7 @@ l'étape 21.)*
│ │ ├── main.py # instance FastAPI
│ │ ├── config.py # settings (pydantic-settings)
│ │ ├── 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
│ │ │ # health, auth, accounts, editions,
│ │ │ # 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 |
| 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 |
| 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
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,
ideal_staff=s.ideal_staff,
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, [])
),
)
+17 -3
View File
@@ -17,7 +17,9 @@ from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.platypus import (
Image,
PageBreak,
Paragraph,
SimpleDocTemplate,
@@ -26,6 +28,8 @@ from reportlab.platypus import (
TableStyle,
)
_PHOTO_BOX_MM = 14 * mm
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_MONTHS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
@@ -41,6 +45,7 @@ def _format_day_fr(date: dt.date) -> str:
class SheetVolunteer:
name: str
phone: str | None
photo: bytes | None = None
@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:
rows = [["Bénévole", "Téléphone"]] + [
[v.name, v.phone or "—"] for v in shift.volunteers
rows = [["", "Bénévole", "Téléphone"]] + [
[_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(
TableStyle(
[
@@ -107,6 +120,7 @@ def _shift_table(shift: SheetShift) -> Table:
("FONTSIZE", (0, 0), (-1, -1), 10.5),
("TOPPADDING", (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),
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey),
]
+31
View File
@@ -1,8 +1,16 @@
"""Feuilles de poste (PDF) — API."""
import base64
from app.models import Assignment, Role
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):
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")
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):
bar = make_position(session, edition, name="Bar central")
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)."""
import base64
import datetime as dt
from app.sheets.pdf import (
@@ -9,6 +10,12 @@ from app.sheets.pdf import (
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:
return PositionDaySheet(
@@ -88,3 +95,26 @@ def test_generates_a_valid_pdf_without_a_referent():
assert sheet.referent_name is None
pdf = generate_position_sheets_pdf([sheet])
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)