diff --git a/CLAUDE.md b/CLAUDE.md index 74934b9..521b099 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + diff --git a/backend/app/api/position_sheets.py b/backend/app/api/position_sheets.py index c519eb4..e75fb86 100644 --- a/backend/app/api/position_sheets.py +++ b/backend/app/api/position_sheets.py @@ -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, []) ), ) diff --git a/backend/app/sheets/pdf.py b/backend/app/sheets/pdf.py index 5380f10..1bdad49 100644 --- a/backend/app/sheets/pdf.py +++ b/backend/app/sheets/pdf.py @@ -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), ] diff --git a/backend/tests/test_position_sheets_api.py b/backend/tests/test_position_sheets_api.py index 2ae43ff..ded3224 100644 --- a/backend/tests/test_position_sheets_api.py +++ b/backend/tests/test_position_sheets_api.py @@ -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") diff --git a/backend/tests/test_position_sheets_pdf.py b/backend/tests/test_position_sheets_pdf.py index 39b8c68..e1299ba 100644 --- a/backend/tests/test_position_sheets_pdf.py +++ b/backend/tests/test_position_sheets_pdf.py @@ -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)