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>
274 lines
9.5 KiB
Python
274 lines
9.5 KiB
Python
"""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,
|
|
)
|