Files
AdamThomasandClaude Opus 5.5 c57c70569c
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Pointage enregistré et statistiques de présence
É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>
2026-10-28 08:06:32 +01:00

236 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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