É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
+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,
)