Files
AdamThomasandClaude Opus 5.5 cc8c2e61ab
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 7s
ci-cd / deploy (push) Successful in 11s
Fiches de poste : message de l'organisation en bas de chaque page
Edition.volunteer_message imprimé en pied de chaque page (suite d'une
feuille comprise), la marge du bas agrandie de sa hauteur ; rien sans
message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:11:33 +01:00

169 lines
6.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.i18n import t
from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
from app.sheets.pdf import (
PositionDaySheet,
SheetArtist,
SheetFooter,
SheetShift,
SheetVolunteer,
generate_position_sheets_pdf,
)
from app.validation.engine import day_boundary, festival_day
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))
# « Jour » = journée de festival (CLAUDE.md § 3), pas date du calendrier :
# une soirée qui passe minuit (23h–00h, 00h–01h…) reste sur une seule
# feuille, celle du jour où elle a commencé.
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
# 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
day = festival_day(shift.starts_at, boundary)
shifts_by_position_day[(shift.position_id, day)].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
}
# Programmation du jour, tous postes confondus — voir `SheetArtist`.
artists_by_date: dict[dt.date, list[Artist]] = defaultdict(list)
for artist in session.exec(
select(Artist)
.where(Artist.edition_id == edition.id)
.order_by(Artist.start_time)
):
artists_by_date[artist.performance_date].append(artist)
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,
artists=tuple(
SheetArtist(name=a.name, start_time=a.start_time, end_time=a.end_time)
for a in artists_by_date.get(date, [])
),
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"}
)
# Message de l'organisation (le même que sur l'écran bénévole) en bas de
# chaque page.
footer = (
SheetFooter(title=t("position_sheets.footer_title"), message=edition.volunteer_message)
if edition.volunteer_message
else None
)
pdf_bytes = generate_position_sheets_pdf(sheets, footer)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="feuilles-de-poste.pdf"'},
)