Disponibilités déclarées supprimées (table `availability`, migration 0022, API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) ainsi que les règles OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus de disponibilité et de repos du solveur et le filtre « Surchargés » de la vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
6.7 KiB
Python
190 lines
6.7 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
|