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>
198 lines
7.0 KiB
Python
198 lines
7.0 KiB
Python
"""Statistiques d'une édition (inscriptions, réponses, paiements, besoins)."""
|
|
|
|
import datetime as dt
|
|
|
|
from app.models import (
|
|
Assignment,
|
|
FeePaymentStatus,
|
|
PendingFormSubmission,
|
|
Role,
|
|
VolunteerArtist,
|
|
VolunteerCompanionWish,
|
|
VolunteerPositionWish,
|
|
)
|
|
from tests.factories import (
|
|
dt_at,
|
|
make_artist,
|
|
make_availability,
|
|
make_position,
|
|
make_shift,
|
|
make_volunteer,
|
|
)
|
|
|
|
|
|
def _url(edition):
|
|
return f"/editions/{edition.id}/stats"
|
|
|
|
|
|
def _registered_on(session, volunteer, day):
|
|
volunteer.created_at = dt.datetime(2026, 6, day, 10, 0)
|
|
session.add(volunteer)
|
|
session.commit()
|
|
|
|
|
|
def test_is_coordinator_only(edition, make_account, as_account):
|
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
|
assert as_account(lead).get(_url(edition)).status_code == 403
|
|
|
|
|
|
def test_empty_edition(client, edition):
|
|
resp = client.get(_url(edition))
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["registrations"]["total"] == 0
|
|
assert body["registrations"]["by_day"] == []
|
|
assert body["payments"]["total_collected"] == 0
|
|
assert body["needs"]["by_day"] == []
|
|
|
|
|
|
def test_registrations_evolution_is_cumulative_with_gap_days_filled(client, edition, session):
|
|
for day in (1, 1, 3):
|
|
_registered_on(session, make_volunteer(session, edition), day)
|
|
|
|
by_day = client.get(_url(edition)).json()["registrations"]["by_day"]
|
|
assert by_day == [
|
|
{"date": "2026-06-01", "count": 2, "cumulative": 2},
|
|
{"date": "2026-06-02", "count": 0, "cumulative": 2},
|
|
{"date": "2026-06-03", "count": 1, "cumulative": 3},
|
|
]
|
|
|
|
|
|
def test_answers_count_volunteers_per_filled_field(client, edition, session):
|
|
bar = make_position(session, edition, name="Bar")
|
|
artist = make_artist(session, edition)
|
|
full = make_volunteer(session, edition, first_name="Alex", email="alex@ex.fr")
|
|
full.phone = "0600000000"
|
|
full.comment = "Dispo le soir"
|
|
full.companion_wish_note = "Sam"
|
|
full.self_declared_available = True
|
|
other = make_volunteer(session, edition, first_name="Sam")
|
|
session.add_all([
|
|
full,
|
|
VolunteerPositionWish(volunteer_id=full.id, position_id=bar.id),
|
|
VolunteerArtist(volunteer_id=full.id, artist_id=artist.id),
|
|
VolunteerCompanionWish(volunteer_id=full.id, companion_id=other.id),
|
|
])
|
|
session.commit()
|
|
|
|
body = client.get(_url(edition)).json()
|
|
answers = body["answers"]
|
|
assert answers["total"] == 2
|
|
for key in (
|
|
"email", "phone", "comment", "companion_note", "available",
|
|
"position_wishes", "artist_wishes", "companion_wishes",
|
|
):
|
|
assert answers[key] == 1, key
|
|
assert answers["photo"] == 0
|
|
assert body["artist_wishes"] == [
|
|
{"id": str(artist.id), "name": "Chilla", "date": "2026-07-10", "count": 1}
|
|
]
|
|
|
|
|
|
def test_payments_split_online_on_site_and_unfinished(client, edition, session):
|
|
donor = make_volunteer(session, edition, first_name="Alex")
|
|
donor.willing_to_pay_fee = True
|
|
donor.fee_payment_label = "Mécénat"
|
|
donor.fee_amount = 50.0
|
|
donor.fee_payment_status = FeePaymentStatus.PAID
|
|
donor.mecenat_address = "1 rue du Festival"
|
|
classic = make_volunteer(session, edition, first_name="Sam")
|
|
classic.willing_to_pay_fee = True
|
|
classic.fee_payment_label = "Cotisation classique"
|
|
classic.fee_amount = 12.5
|
|
classic.fee_payment_status = FeePaymentStatus.PAID
|
|
on_site = make_volunteer(session, edition, first_name="Jo")
|
|
on_site.willing_to_pay_fee = True
|
|
make_volunteer(session, edition, first_name="Lou")
|
|
session.add_all([
|
|
donor,
|
|
classic,
|
|
on_site,
|
|
# Formulaire rempli, paiement jamais abouti : pas de bénévole créé.
|
|
PendingFormSubmission(
|
|
edition_id=edition.id, payment_label="Mécénat", payment_amount=30.0,
|
|
checkout_status=FeePaymentStatus.FAILED,
|
|
),
|
|
])
|
|
session.commit()
|
|
|
|
payments = client.get(_url(edition)).json()["payments"]
|
|
assert payments["online"] == 2
|
|
assert payments["on_site"] == 1
|
|
assert payments["none"] == 1
|
|
assert payments["pending_unfinished"] == 1
|
|
assert payments["total_collected"] == 62.5
|
|
assert payments["mecenat_count"] == 1
|
|
assert payments["mecenat_total"] == 50.0
|
|
assert payments["by_option"] == [
|
|
{"label": "Cotisation classique", "mecenat": False, "count": 1, "amount_total": 12.5},
|
|
{"label": "Mécénat", "mecenat": True, "count": 1, "amount_total": 50.0},
|
|
]
|
|
|
|
|
|
def test_needs_are_grouped_by_festival_day(client, edition, session):
|
|
# Début de journée = 06:00 (fixture) : la soirée du 9 déborde après minuit.
|
|
bar = make_position(session, edition, name="Bar")
|
|
late = make_shift(
|
|
session, edition, bar, start=dt_at(9, 22), end=dt_at(10, 0), min_staff=1, ideal_staff=2,
|
|
)
|
|
make_shift(
|
|
session, edition, bar, start=dt_at(10, 0), end=dt_at(10, 1), min_staff=1, ideal_staff=1,
|
|
)
|
|
alex = make_volunteer(session, edition, first_name="Alex")
|
|
sam = make_volunteer(session, edition, first_name="Sam")
|
|
make_availability(
|
|
session, edition, alex,
|
|
date=dt.date(2026, 7, 9), start_time=dt.time(20, 0), end_time=dt.time(2, 0),
|
|
)
|
|
session.add_all([
|
|
Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=late.id),
|
|
VolunteerPositionWish(volunteer_id=alex.id, position_id=bar.id),
|
|
VolunteerPositionWish(volunteer_id=sam.id, position_id=bar.id),
|
|
])
|
|
session.commit()
|
|
|
|
needs = client.get(_url(edition)).json()["needs"]
|
|
assert needs["by_day"] == [
|
|
{
|
|
"date": "2026-07-09",
|
|
"shifts": 2,
|
|
"min_hours": 3.0,
|
|
"ideal_hours": 5.0,
|
|
"available_hours": 6.0,
|
|
"available_volunteers": 1,
|
|
"assigned_hours": 2.0,
|
|
}
|
|
]
|
|
assert needs["by_position"] == [
|
|
{
|
|
"position_id": str(bar.id),
|
|
"name": "Bar",
|
|
"shifts": 2,
|
|
"min_slots": 2,
|
|
"ideal_slots": 3,
|
|
"assigned": 1,
|
|
"filled": 1,
|
|
"wishes": 2,
|
|
}
|
|
]
|
|
assert needs["volunteers_with_availability"] == 1
|
|
assert needs["volunteers_with_assignment"] == 1
|
|
|
|
|
|
def test_filled_slots_never_count_overstaffing(client, edition, session):
|
|
# 3 affectés sur un créneau qui n'en demande que 2 : 2 places pourvues,
|
|
# pas 3 — sinon un sureffectif masquerait un trou ailleurs.
|
|
bar = make_position(session, edition, name="Bar")
|
|
full = make_shift(session, edition, bar, start=dt_at(9, 18), end=dt_at(9, 20), ideal_staff=2)
|
|
make_shift(session, edition, bar, start=dt_at(9, 20), end=dt_at(9, 22), ideal_staff=2)
|
|
for name in ("A", "B", "C"):
|
|
v = make_volunteer(session, edition, first_name=name)
|
|
session.add(Assignment(edition_id=edition.id, volunteer_id=v.id, shift_id=full.id))
|
|
session.commit()
|
|
|
|
position = client.get(_url(edition)).json()["needs"]["by_position"][0]
|
|
assert position["assigned"] == 3
|
|
assert position["filled"] == 2
|
|
assert position["ideal_slots"] == 4
|