Étape 58 : le présent / absent du référent est enregistré sur
l'affectation (Assignment.attendance + heure, migration 0030,
PUT .../assignments/{id}/attendance) au lieu de disparaître au
rechargement.
Étape 59 : section « Présence » de l'écran Statistiques — créneaux en
cours et créneaux terminés, par poste, rafraîchie toutes les 30 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
8.5 KiB
Python
236 lines
8.5 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_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")
|
||
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,
|
||
"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_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
|
||
|
||
|
||
def test_attendance_splits_past_and_live_shifts_per_position(
|
||
client, edition, session, monkeypatch
|
||
):
|
||
import app.api.stats as stats_api
|
||
from app.models import AttendanceStatus
|
||
|
||
# Maintenant : le 10 à 20:00 — 14h–18h terminé, 18h–22h en cours,
|
||
# 22h–02h à venir (ignoré).
|
||
monkeypatch.setattr(stats_api, "local_now", lambda: dt_at(10, 20))
|
||
bar = make_position(session, edition, name="Bar")
|
||
parking = make_position(session, edition, name="Parking")
|
||
past = make_shift(session, edition, bar, start=dt_at(10, 14), end=dt_at(10, 18))
|
||
live = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
|
||
later = make_shift(session, edition, parking, start=dt_at(10, 22), end=dt_at(11, 2))
|
||
|
||
def assign(shift, name, attendance=None):
|
||
volunteer = make_volunteer(session, edition, first_name=name)
|
||
session.add(
|
||
Assignment(
|
||
edition_id=edition.id,
|
||
volunteer_id=volunteer.id,
|
||
shift_id=shift.id,
|
||
attendance=attendance,
|
||
)
|
||
)
|
||
|
||
assign(past, "A", AttendanceStatus.PRESENT)
|
||
assign(past, "B", AttendanceStatus.ABSENT)
|
||
assign(past, "C")
|
||
assign(live, "D", AttendanceStatus.PRESENT)
|
||
assign(live, "E")
|
||
assign(later, "F", AttendanceStatus.PRESENT)
|
||
session.commit()
|
||
|
||
att = client.get(_url(edition)).json()["attendance"]
|
||
assert att["past"] == {"assigned": 3, "present": 1, "absent": 1, "unmarked": 1}
|
||
assert att["live"] == {"assigned": 2, "present": 1, "absent": 0, "unmarked": 1}
|
||
assert att["live_shifts"] == 1
|
||
# Parking n'a encore aucun créneau commencé : absent du détail.
|
||
(row,) = att["by_position"]
|
||
assert row["name"] == "Bar"
|
||
assert row["past"]["assigned"] == 3
|
||
assert row["live"]["present"] == 1
|
||
assert row["live_shifts"] == 1
|