"""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