Files
benevoles/backend/app/api/position_sheets.py
T
AdamThomasandClaude Sonnet 5 f6fbfaf82a 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>
2026-09-22 20:06:51 +02:00

137 lines
5.2 KiB
Python

"""Feuilles de poste (PDF) : une page par poste et par jour, listant les
créneaux du jour avec les bénévoles affectés et leur téléphone — à imprimer
et afficher sur place. Complément des badges (`app/api/badges.py`) : un
badge identifie un bénévole, une feuille identifie un poste pour la
journée. Voir `app/sheets/pdf.py` pour la composition du PDF.
"""
import datetime as dt
import uuid
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import aliased
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import PositionSheetsRequest
from app.auth.deps import require_coordinator
from app.db import get_session
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
from app.sheets.pdf import (
PositionDaySheet,
SheetShift,
SheetVolunteer,
generate_position_sheets_pdf,
)
router = APIRouter(
prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"]
)
@router.post("/generate")
def generate_position_sheets(
payload: PositionSheetsRequest,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
query = select(Position).where(Position.edition_id == edition.id)
if payload.position_ids is not None:
query = query.where(Position.id.in_(payload.position_ids))
positions = list(session.exec(query.order_by(Position.name)))
if not positions:
raise HTTPException(
status_code=422, detail={"code": "position_sheets.no_positions"}
)
position_ids = [p.id for p in positions]
shifts = list(
session.exec(
select(Shift)
.where(Shift.position_id.in_(position_ids))
.order_by(Shift.starts_at)
)
)
shift_ids = [s.id for s in shifts]
volunteers_by_shift: dict[uuid.UUID, list[Volunteer]] = defaultdict(list)
if shift_ids:
rows = session.exec(
select(Assignment.shift_id, Volunteer)
.join(Volunteer, Volunteer.id == Assignment.volunteer_id)
.where(Assignment.shift_id.in_(shift_ids))
).all()
for shift_id, volunteer in rows:
volunteers_by_shift[shift_id].append(volunteer)
for vs in volunteers_by_shift.values():
vs.sort(key=lambda v: (v.last_name, v.first_name))
# Un jour sans aucun besoin réel (créneau 0/0 généré automatiquement,
# jamais rempli) n'apparaît pas — évite des pages « personne affecté »
# pour des créneaux qui ne représentent aucun besoin.
shifts_by_position_day: dict[tuple[uuid.UUID, dt.date], list[Shift]] = defaultdict(list)
for shift in shifts:
undefined_need = shift.min_staff == 0 and shift.ideal_staff == 0
if undefined_need and not volunteers_by_shift.get(shift.id):
continue
shifts_by_position_day[(shift.position_id, shift.starts_at.date())].append(shift)
Referent = aliased(Volunteer)
referent_rows = session.exec(
select(Position.id, Referent)
.join(Account, Account.id == Position.lead_account_id, isouter=True)
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
.where(Position.id.in_(position_ids))
).all()
referent_by_position: dict[uuid.UUID, Volunteer] = {
pid: referent for pid, referent in referent_rows if referent is not None
}
positions_by_id = {p.id: p for p in positions}
def _sheet(position_id: uuid.UUID, date: dt.date, day_shifts: list[Shift]) -> PositionDaySheet:
referent = referent_by_position.get(position_id)
return PositionDaySheet(
position_name=positions_by_id[position_id].name,
position_location=positions_by_id[position_id].location,
date=date,
referent_name=f"{referent.first_name} {referent.last_name}" if referent else None,
referent_phone=referent.phone if referent else None,
shifts=tuple(
SheetShift(
starts_at=s.starts_at,
ends_at=s.ends_at,
min_staff=s.min_staff,
ideal_staff=s.ideal_staff,
volunteers=tuple(
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 s in sorted(day_shifts, key=lambda s: s.starts_at)
),
)
sheets = [
_sheet(position_id, date, day_shifts)
for (position_id, date), day_shifts in sorted(
shifts_by_position_day.items(),
key=lambda item: (positions_by_id[item[0][0]].name, item[0][1]),
)
]
if not sheets:
raise HTTPException(
status_code=422, detail={"code": "position_sheets.no_shifts"}
)
pdf_bytes = generate_position_sheets_pdf(sheets)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="feuilles-de-poste.pdf"'},
)