Écran Statistiques : inscriptions, réponses, cotisations et besoins
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s

Nouvel onglet « Statistiques » (/statistiques, COORDINATION), alimenté
par GET /editions/{id}/stats (app/api/stats.py, recalculé à chaque appel,
aucune nouvelle entité) :
- inscriptions : total, 7 derniers jours, évolution cumulée par jour
- réponses : part des bénévoles ayant renseigné chaque information,
  artistes les plus demandés
- cotisations : en ligne / sur place / aucune, montants par type,
  mécénat, formulaires remplis sans paiement abouti
- besoins face aux bénévoles, par journée de festival : heures × personnes
  demandées vs disponibilités déclarées vs affectées ; par poste, places
  pourvues (plafonnées à l'effectif idéal de chaque créneau) et rapport
  vœux / place

Graphiques SVG maison (components/charts.tsx) avec infobulles au survol
et au clavier, vue tableau pour chacun, palette validée (daltonisme,
contraste), mise en page vérifiée sur ordinateur et mobile. Le mock
calcule les mêmes chiffres et étale les inscriptions du jeu de démo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-04 19:40:46 +02:00
co-authored by Claude Opus 5.5
parent ff57bbd863
commit ee1f6ed72e
17 changed files with 1775 additions and 2 deletions
+108
View File
@@ -647,3 +647,111 @@ class SumUpWebhookPayload(ApiModel):
event_type: str
id: str
# --------------------------------------------------------------------------- #
# Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) #
# --------------------------------------------------------------------------- #
class StatsRegistrationDay(ApiModel):
date: dt.date
count: int
cumulative: int
class StatsRegistrations(ApiModel):
"""Bénévoles en base, par jour d'ajout (`Volunteer.created_at`) — jours
sans inscription inclus (à 0), pour une courbe sans trou."""
total: int
last_7_days: int
by_day: list[StatsRegistrationDay]
class StatsAnswers(ApiModel):
"""Nombre de bénévoles ayant renseigné chaque information (sur `total`)."""
total: int
email: int
phone: int
photo: int
comment: int
companion_note: int
available: int
position_wishes: int
artist_wishes: int
companion_wishes: int
class StatsPaymentOption(ApiModel):
label: str
mecenat: bool
count: int
amount_total: float
class StatsPayments(ApiModel):
"""Mode de paiement (même règle que `lib/fee.ts:feeMode`) : `online`
(paiement en ligne confirmé), `on_site`, `none`. `pending_unfinished` :
formulaires remplis dont le paiement en ligne n'a jamais abouti — aucun
bénévole créé pour eux (`PendingFormSubmission` non promue)."""
online: int
on_site: int
none: int
pending_unfinished: int
total_collected: float
mecenat_count: int
mecenat_total: float
by_option: list[StatsPaymentOption]
class StatsArtistWish(ApiModel):
id: uuid.UUID
name: str
date: dt.date
count: int
class StatsNeedDay(ApiModel):
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
× personnes demandées (effectif minimal / idéal), heures de disponibilité
déclarées, heures déjà affectées."""
date: dt.date
shifts: int
min_hours: float
ideal_hours: float
available_hours: float
available_volunteers: int
assigned_hours: float
class StatsNeedPosition(ApiModel):
"""Places (personnes × créneaux) demandées et pourvues pour un poste, et
nombre de bénévoles qui l'ont mis dans leurs vœux."""
position_id: uuid.UUID
name: str
shifts: int
min_slots: int
ideal_slots: int
assigned: int
# Places pourvues, plafonnées créneau par créneau à l'effectif idéal : un
# sureffectif ne compense jamais un trou ailleurs.
filled: int
wishes: int
class StatsNeeds(ApiModel):
by_day: list[StatsNeedDay]
by_position: list[StatsNeedPosition]
volunteers_with_availability: int
volunteers_with_assignment: int
class EditionStats(ApiModel):
registrations: StatsRegistrations
answers: StatsAnswers
payments: StatsPayments
artist_wishes: list[StatsArtistWish]
needs: StatsNeeds
+273
View File
@@ -0,0 +1,273 @@
"""Statistiques d'une édition : inscriptions (nombre, évolution), réponses au
formulaire, paiements de la cotisation, besoins face aux disponibilités.
Lecture seule, recalculée à chaque appel (quelques centaines de lignes au
plus pour un festival de 50 à 200 bénévoles) — rien n'est stocké.
"""
import datetime as dt
from collections import Counter, defaultdict
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import (
EditionStats,
StatsAnswers,
StatsArtistWish,
StatsNeedDay,
StatsNeedPosition,
StatsNeeds,
StatsPaymentOption,
StatsPayments,
StatsRegistrationDay,
StatsRegistrations,
)
from app.auth.deps import require_coordinator
from app.db import get_session
from app.models import (
Account,
Artist,
Assignment,
Availability,
DayPart,
Edition,
FeePaymentStatus,
PendingFormSubmission,
Position,
Shift,
Volunteer,
VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish,
local_now,
)
from app.validation.engine import day_boundary, festival_day
router = APIRouter(prefix="/editions/{edition_id}/stats", tags=["stats"])
def _hours(starts_at: dt.datetime, ends_at: dt.datetime) -> float:
return (ends_at - starts_at).total_seconds() / 3600
def _round(value: float) -> float:
return round(value, 2)
def _registrations(volunteers: list[Volunteer]) -> StatsRegistrations:
per_day = Counter(v.created_at.date() for v in volunteers)
by_day: list[StatsRegistrationDay] = []
if per_day:
day, last, cumulative = min(per_day), max(per_day), 0
while day <= last:
cumulative += per_day[day]
by_day.append(
StatsRegistrationDay(date=day, count=per_day[day], cumulative=cumulative)
)
day += dt.timedelta(days=1)
since = local_now() - dt.timedelta(days=7)
return StatsRegistrations(
total=len(volunteers),
last_7_days=sum(1 for v in volunteers if v.created_at >= since),
by_day=by_day,
)
def _payments(
volunteers: list[Volunteer], pending_unfinished: int
) -> StatsPayments:
# Même règle que `lib/fee.ts:feeMode` / `exporting/xlsx.py:_payment_mode`.
online = [v for v in volunteers if v.fee_payment_status is not None]
paid = [v for v in online if v.fee_payment_status == FeePaymentStatus.PAID]
by_label: dict[str, list[Volunteer]] = defaultdict(list)
for v in paid:
by_label[v.fee_payment_label or ""].append(v)
mecenat = [v for v in paid if v.mecenat_address]
return StatsPayments(
online=len(online),
on_site=sum(
1 for v in volunteers if v.fee_payment_status is None and v.willing_to_pay_fee
),
none=sum(
1 for v in volunteers if v.fee_payment_status is None and not v.willing_to_pay_fee
),
pending_unfinished=pending_unfinished,
total_collected=_round(sum(v.fee_amount or 0 for v in paid)),
mecenat_count=len(mecenat),
mecenat_total=_round(sum(v.fee_amount or 0 for v in mecenat)),
by_option=[
StatsPaymentOption(
label=label,
mecenat=any(v.mecenat_address for v in group),
count=len(group),
amount_total=_round(sum(v.fee_amount or 0 for v in group)),
)
for label, group in sorted(by_label.items())
],
)
@router.get("", response_model=EditionStats)
def get_stats(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> EditionStats:
volunteers = list(session.exec(select(Volunteer).where(Volunteer.edition_id == edition.id)))
volunteer_ids = [v.id for v in volunteers]
positions = list(
session.exec(
select(Position).where(Position.edition_id == edition.id).order_by(Position.name)
)
)
shifts = list(session.exec(select(Shift).where(Shift.edition_id == edition.id)))
assignments = list(
session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
)
availabilities = list(
session.exec(select(Availability).where(Availability.edition_id == edition.id))
)
artists = list(
session.exec(
select(Artist)
.where(Artist.edition_id == edition.id)
.order_by(Artist.performance_date, Artist.start_time)
)
)
position_wishes = (
list(
session.exec(
select(VolunteerPositionWish).where(
VolunteerPositionWish.volunteer_id.in_(volunteer_ids)
)
)
)
if volunteer_ids
else []
)
artist_wishes = (
list(
session.exec(
select(VolunteerArtist).where(VolunteerArtist.volunteer_id.in_(volunteer_ids))
)
)
if volunteer_ids
else []
)
companion_wishers = (
set(
session.exec(
select(VolunteerCompanionWish.volunteer_id).where(
VolunteerCompanionWish.volunteer_id.in_(volunteer_ids)
)
)
)
if volunteer_ids
else set()
)
pending_unfinished = len(
session.exec(
select(PendingFormSubmission.id).where(
PendingFormSubmission.edition_id == edition.id,
PendingFormSubmission.promoted_volunteer_id.is_(None),
)
).all()
)
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
answers = StatsAnswers(
total=len(volunteers),
email=sum(1 for v in volunteers if v.email),
phone=sum(1 for v in volunteers if v.phone),
photo=sum(1 for v in volunteers if v.photo is not None),
comment=sum(1 for v in volunteers if v.comment),
companion_note=sum(1 for v in volunteers if v.companion_wish_note),
available=sum(1 for v in volunteers if v.self_declared_available),
position_wishes=len({w.volunteer_id for w in position_wishes}),
artist_wishes=len({w.volunteer_id for w in artist_wishes}),
companion_wishes=len(companion_wishers),
)
fans = Counter(w.artist_id for w in artist_wishes)
artist_stats = [
StatsArtistWish(id=a.id, name=a.name, date=a.performance_date, count=fans[a.id])
for a in artists
]
# Besoins par journée de festival : une soirée qui passe minuit reste sur
# le jour où elle a commencé (CLAUDE.md § 3).
shifts_by_id = {s.id: s for s in shifts}
day_rows: dict[dt.date, dict] = defaultdict(
lambda: {
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "available_hours": 0.0,
"available_volunteers": set(), "assigned_hours": 0.0,
}
)
for s in shifts:
row = day_rows[festival_day(s.starts_at, boundary)]
hours = _hours(s.starts_at, s.ends_at)
row["shifts"] += 1
row["min_hours"] += hours * s.min_staff
row["ideal_hours"] += hours * s.ideal_staff
for a in assignments:
s = shifts_by_id.get(a.shift_id)
if s is not None:
day_rows[festival_day(s.starts_at, boundary)]["assigned_hours"] += _hours(
s.starts_at, s.ends_at
)
for w in availabilities:
starts_at = dt.datetime.combine(w.date, w.start_time)
end_date = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
row = day_rows[festival_day(starts_at, boundary)]
row["available_hours"] += _hours(starts_at, dt.datetime.combine(end_date, w.end_time))
row["available_volunteers"].add(w.volunteer_id)
wishers = Counter(w.position_id for w in position_wishes)
assigned_by_shift = Counter(a.shift_id for a in assignments)
shifts_by_position: dict = defaultdict(list)
for s in shifts:
shifts_by_position[s.position_id].append(s)
needs = StatsNeeds(
by_day=[
StatsNeedDay(
date=day,
shifts=row["shifts"],
min_hours=_round(row["min_hours"]),
ideal_hours=_round(row["ideal_hours"]),
available_hours=_round(row["available_hours"]),
available_volunteers=len(row["available_volunteers"]),
assigned_hours=_round(row["assigned_hours"]),
)
for day, row in sorted(day_rows.items())
],
by_position=[
StatsNeedPosition(
position_id=p.id,
name=p.name,
shifts=len(shifts_by_position[p.id]),
min_slots=sum(s.min_staff for s in shifts_by_position[p.id]),
ideal_slots=sum(s.ideal_staff for s in shifts_by_position[p.id]),
assigned=sum(assigned_by_shift[s.id] for s in shifts_by_position[p.id]),
filled=sum(
min(assigned_by_shift[s.id], s.ideal_staff) for s in shifts_by_position[p.id]
),
wishes=wishers[p.id],
)
for p in positions
],
volunteers_with_availability=len({w.volunteer_id for w in availabilities}),
volunteers_with_assignment=len({a.volunteer_id for a in assignments}),
)
return EditionStats(
registrations=_registrations(volunteers),
answers=answers,
payments=_payments(volunteers, pending_unfinished),
artist_wishes=artist_stats,
needs=needs,
)