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:
co-authored by
Claude Sonnet 5
parent
15109acfe5
commit
f6fbfaf82a
@@ -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 +
|
||||||
|
|||||||
@@ -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,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),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user