Écran Statistiques : inscriptions, réponses, cotisations et besoins
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ff57bbd863
commit
ee1f6ed72e
@@ -647,3 +647,111 @@ class SumUpWebhookPayload(ApiModel):
|
||||
|
||||
event_type: str
|
||||
id: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class StatsRegistrationDay(ApiModel):
|
||||
date: dt.date
|
||||
count: int
|
||||
cumulative: int
|
||||
|
||||
|
||||
class StatsRegistrations(ApiModel):
|
||||
"""Bénévoles en base, par jour d'ajout (`Volunteer.created_at`) — jours
|
||||
sans inscription inclus (à 0), pour une courbe sans trou."""
|
||||
|
||||
total: int
|
||||
last_7_days: int
|
||||
by_day: list[StatsRegistrationDay]
|
||||
|
||||
|
||||
class StatsAnswers(ApiModel):
|
||||
"""Nombre de bénévoles ayant renseigné chaque information (sur `total`)."""
|
||||
|
||||
total: int
|
||||
email: int
|
||||
phone: int
|
||||
photo: int
|
||||
comment: int
|
||||
companion_note: int
|
||||
available: int
|
||||
position_wishes: int
|
||||
artist_wishes: int
|
||||
companion_wishes: int
|
||||
|
||||
|
||||
class StatsPaymentOption(ApiModel):
|
||||
label: str
|
||||
mecenat: bool
|
||||
count: int
|
||||
amount_total: float
|
||||
|
||||
|
||||
class StatsPayments(ApiModel):
|
||||
"""Mode de paiement (même règle que `lib/fee.ts:feeMode`) : `online`
|
||||
(paiement en ligne confirmé), `on_site`, `none`. `pending_unfinished` :
|
||||
formulaires remplis dont le paiement en ligne n'a jamais abouti — aucun
|
||||
bénévole créé pour eux (`PendingFormSubmission` non promue)."""
|
||||
|
||||
online: int
|
||||
on_site: int
|
||||
none: int
|
||||
pending_unfinished: int
|
||||
total_collected: float
|
||||
mecenat_count: int
|
||||
mecenat_total: float
|
||||
by_option: list[StatsPaymentOption]
|
||||
|
||||
|
||||
class StatsArtistWish(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
date: dt.date
|
||||
count: int
|
||||
|
||||
|
||||
class StatsNeedDay(ApiModel):
|
||||
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
|
||||
× personnes demandées (effectif minimal / idéal), heures de disponibilité
|
||||
déclarées, heures déjà affectées."""
|
||||
|
||||
date: dt.date
|
||||
shifts: int
|
||||
min_hours: float
|
||||
ideal_hours: float
|
||||
available_hours: float
|
||||
available_volunteers: int
|
||||
assigned_hours: float
|
||||
|
||||
|
||||
class StatsNeedPosition(ApiModel):
|
||||
"""Places (personnes × créneaux) demandées et pourvues pour un poste, et
|
||||
nombre de bénévoles qui l'ont mis dans leurs vœux."""
|
||||
|
||||
position_id: uuid.UUID
|
||||
name: str
|
||||
shifts: int
|
||||
min_slots: int
|
||||
ideal_slots: int
|
||||
assigned: int
|
||||
# Places pourvues, plafonnées créneau par créneau à l'effectif idéal : un
|
||||
# sureffectif ne compense jamais un trou ailleurs.
|
||||
filled: int
|
||||
wishes: int
|
||||
|
||||
|
||||
class StatsNeeds(ApiModel):
|
||||
by_day: list[StatsNeedDay]
|
||||
by_position: list[StatsNeedPosition]
|
||||
volunteers_with_availability: int
|
||||
volunteers_with_assignment: int
|
||||
|
||||
|
||||
class EditionStats(ApiModel):
|
||||
registrations: StatsRegistrations
|
||||
answers: StatsAnswers
|
||||
payments: StatsPayments
|
||||
artist_wishes: list[StatsArtistWish]
|
||||
needs: StatsNeeds
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Statistiques d'une édition : inscriptions (nombre, évolution), réponses au
|
||||
formulaire, paiements de la cotisation, besoins face aux disponibilités.
|
||||
|
||||
Lecture seule, recalculée à chaque appel (quelques centaines de lignes au
|
||||
plus pour un festival de 50 à 200 bénévoles) — rien n'est stocké.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
EditionStats,
|
||||
StatsAnswers,
|
||||
StatsArtistWish,
|
||||
StatsNeedDay,
|
||||
StatsNeedPosition,
|
||||
StatsNeeds,
|
||||
StatsPaymentOption,
|
||||
StatsPayments,
|
||||
StatsRegistrationDay,
|
||||
StatsRegistrations,
|
||||
)
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.db import get_session
|
||||
from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
PendingFormSubmission,
|
||||
Position,
|
||||
Shift,
|
||||
Volunteer,
|
||||
VolunteerArtist,
|
||||
VolunteerCompanionWish,
|
||||
VolunteerPositionWish,
|
||||
local_now,
|
||||
)
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/stats", tags=["stats"])
|
||||
|
||||
|
||||
def _hours(starts_at: dt.datetime, ends_at: dt.datetime) -> float:
|
||||
return (ends_at - starts_at).total_seconds() / 3600
|
||||
|
||||
|
||||
def _round(value: float) -> float:
|
||||
return round(value, 2)
|
||||
|
||||
|
||||
def _registrations(volunteers: list[Volunteer]) -> StatsRegistrations:
|
||||
per_day = Counter(v.created_at.date() for v in volunteers)
|
||||
by_day: list[StatsRegistrationDay] = []
|
||||
if per_day:
|
||||
day, last, cumulative = min(per_day), max(per_day), 0
|
||||
while day <= last:
|
||||
cumulative += per_day[day]
|
||||
by_day.append(
|
||||
StatsRegistrationDay(date=day, count=per_day[day], cumulative=cumulative)
|
||||
)
|
||||
day += dt.timedelta(days=1)
|
||||
since = local_now() - dt.timedelta(days=7)
|
||||
return StatsRegistrations(
|
||||
total=len(volunteers),
|
||||
last_7_days=sum(1 for v in volunteers if v.created_at >= since),
|
||||
by_day=by_day,
|
||||
)
|
||||
|
||||
|
||||
def _payments(
|
||||
volunteers: list[Volunteer], pending_unfinished: int
|
||||
) -> StatsPayments:
|
||||
# Même règle que `lib/fee.ts:feeMode` / `exporting/xlsx.py:_payment_mode`.
|
||||
online = [v for v in volunteers if v.fee_payment_status is not None]
|
||||
paid = [v for v in online if v.fee_payment_status == FeePaymentStatus.PAID]
|
||||
by_label: dict[str, list[Volunteer]] = defaultdict(list)
|
||||
for v in paid:
|
||||
by_label[v.fee_payment_label or ""].append(v)
|
||||
mecenat = [v for v in paid if v.mecenat_address]
|
||||
return StatsPayments(
|
||||
online=len(online),
|
||||
on_site=sum(
|
||||
1 for v in volunteers if v.fee_payment_status is None and v.willing_to_pay_fee
|
||||
),
|
||||
none=sum(
|
||||
1 for v in volunteers if v.fee_payment_status is None and not v.willing_to_pay_fee
|
||||
),
|
||||
pending_unfinished=pending_unfinished,
|
||||
total_collected=_round(sum(v.fee_amount or 0 for v in paid)),
|
||||
mecenat_count=len(mecenat),
|
||||
mecenat_total=_round(sum(v.fee_amount or 0 for v in mecenat)),
|
||||
by_option=[
|
||||
StatsPaymentOption(
|
||||
label=label,
|
||||
mecenat=any(v.mecenat_address for v in group),
|
||||
count=len(group),
|
||||
amount_total=_round(sum(v.fee_amount or 0 for v in group)),
|
||||
)
|
||||
for label, group in sorted(by_label.items())
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=EditionStats)
|
||||
def get_stats(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> EditionStats:
|
||||
volunteers = list(session.exec(select(Volunteer).where(Volunteer.edition_id == edition.id)))
|
||||
volunteer_ids = [v.id for v in volunteers]
|
||||
positions = list(
|
||||
session.exec(
|
||||
select(Position).where(Position.edition_id == edition.id).order_by(Position.name)
|
||||
)
|
||||
)
|
||||
shifts = list(session.exec(select(Shift).where(Shift.edition_id == edition.id)))
|
||||
assignments = list(
|
||||
session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
|
||||
)
|
||||
availabilities = list(
|
||||
session.exec(select(Availability).where(Availability.edition_id == edition.id))
|
||||
)
|
||||
artists = list(
|
||||
session.exec(
|
||||
select(Artist)
|
||||
.where(Artist.edition_id == edition.id)
|
||||
.order_by(Artist.performance_date, Artist.start_time)
|
||||
)
|
||||
)
|
||||
position_wishes = (
|
||||
list(
|
||||
session.exec(
|
||||
select(VolunteerPositionWish).where(
|
||||
VolunteerPositionWish.volunteer_id.in_(volunteer_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
if volunteer_ids
|
||||
else []
|
||||
)
|
||||
artist_wishes = (
|
||||
list(
|
||||
session.exec(
|
||||
select(VolunteerArtist).where(VolunteerArtist.volunteer_id.in_(volunteer_ids))
|
||||
)
|
||||
)
|
||||
if volunteer_ids
|
||||
else []
|
||||
)
|
||||
companion_wishers = (
|
||||
set(
|
||||
session.exec(
|
||||
select(VolunteerCompanionWish.volunteer_id).where(
|
||||
VolunteerCompanionWish.volunteer_id.in_(volunteer_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
if volunteer_ids
|
||||
else set()
|
||||
)
|
||||
pending_unfinished = len(
|
||||
session.exec(
|
||||
select(PendingFormSubmission.id).where(
|
||||
PendingFormSubmission.edition_id == edition.id,
|
||||
PendingFormSubmission.promoted_volunteer_id.is_(None),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
boundary = day_boundary(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
|
||||
answers = StatsAnswers(
|
||||
total=len(volunteers),
|
||||
email=sum(1 for v in volunteers if v.email),
|
||||
phone=sum(1 for v in volunteers if v.phone),
|
||||
photo=sum(1 for v in volunteers if v.photo is not None),
|
||||
comment=sum(1 for v in volunteers if v.comment),
|
||||
companion_note=sum(1 for v in volunteers if v.companion_wish_note),
|
||||
available=sum(1 for v in volunteers if v.self_declared_available),
|
||||
position_wishes=len({w.volunteer_id for w in position_wishes}),
|
||||
artist_wishes=len({w.volunteer_id for w in artist_wishes}),
|
||||
companion_wishes=len(companion_wishers),
|
||||
)
|
||||
|
||||
fans = Counter(w.artist_id for w in artist_wishes)
|
||||
artist_stats = [
|
||||
StatsArtistWish(id=a.id, name=a.name, date=a.performance_date, count=fans[a.id])
|
||||
for a in artists
|
||||
]
|
||||
|
||||
# Besoins par journée de festival : une soirée qui passe minuit reste sur
|
||||
# le jour où elle a commencé (CLAUDE.md § 3).
|
||||
shifts_by_id = {s.id: s for s in shifts}
|
||||
day_rows: dict[dt.date, dict] = defaultdict(
|
||||
lambda: {
|
||||
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "available_hours": 0.0,
|
||||
"available_volunteers": set(), "assigned_hours": 0.0,
|
||||
}
|
||||
)
|
||||
for s in shifts:
|
||||
row = day_rows[festival_day(s.starts_at, boundary)]
|
||||
hours = _hours(s.starts_at, s.ends_at)
|
||||
row["shifts"] += 1
|
||||
row["min_hours"] += hours * s.min_staff
|
||||
row["ideal_hours"] += hours * s.ideal_staff
|
||||
for a in assignments:
|
||||
s = shifts_by_id.get(a.shift_id)
|
||||
if s is not None:
|
||||
day_rows[festival_day(s.starts_at, boundary)]["assigned_hours"] += _hours(
|
||||
s.starts_at, s.ends_at
|
||||
)
|
||||
for w in availabilities:
|
||||
starts_at = dt.datetime.combine(w.date, w.start_time)
|
||||
end_date = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
|
||||
row = day_rows[festival_day(starts_at, boundary)]
|
||||
row["available_hours"] += _hours(starts_at, dt.datetime.combine(end_date, w.end_time))
|
||||
row["available_volunteers"].add(w.volunteer_id)
|
||||
|
||||
wishers = Counter(w.position_id for w in position_wishes)
|
||||
assigned_by_shift = Counter(a.shift_id for a in assignments)
|
||||
shifts_by_position: dict = defaultdict(list)
|
||||
for s in shifts:
|
||||
shifts_by_position[s.position_id].append(s)
|
||||
|
||||
needs = StatsNeeds(
|
||||
by_day=[
|
||||
StatsNeedDay(
|
||||
date=day,
|
||||
shifts=row["shifts"],
|
||||
min_hours=_round(row["min_hours"]),
|
||||
ideal_hours=_round(row["ideal_hours"]),
|
||||
available_hours=_round(row["available_hours"]),
|
||||
available_volunteers=len(row["available_volunteers"]),
|
||||
assigned_hours=_round(row["assigned_hours"]),
|
||||
)
|
||||
for day, row in sorted(day_rows.items())
|
||||
],
|
||||
by_position=[
|
||||
StatsNeedPosition(
|
||||
position_id=p.id,
|
||||
name=p.name,
|
||||
shifts=len(shifts_by_position[p.id]),
|
||||
min_slots=sum(s.min_staff for s in shifts_by_position[p.id]),
|
||||
ideal_slots=sum(s.ideal_staff for s in shifts_by_position[p.id]),
|
||||
assigned=sum(assigned_by_shift[s.id] for s in shifts_by_position[p.id]),
|
||||
filled=sum(
|
||||
min(assigned_by_shift[s.id], s.ideal_staff) for s in shifts_by_position[p.id]
|
||||
),
|
||||
wishes=wishers[p.id],
|
||||
)
|
||||
for p in positions
|
||||
],
|
||||
volunteers_with_availability=len({w.volunteer_id for w in availabilities}),
|
||||
volunteers_with_assignment=len({a.volunteer_id for a in assignments}),
|
||||
)
|
||||
|
||||
return EditionStats(
|
||||
registrations=_registrations(volunteers),
|
||||
answers=answers,
|
||||
payments=_payments(volunteers, pending_unfinished),
|
||||
artist_wishes=artist_stats,
|
||||
needs=needs,
|
||||
)
|
||||
@@ -20,6 +20,7 @@ from app.api.position_sheets import router as position_sheets_router
|
||||
from app.api.positions import router as position_router
|
||||
from app.api.shifts import router as shift_router
|
||||
from app.api.solver import router as solver_router
|
||||
from app.api.stats import router as stats_router
|
||||
from app.api.volunteers import router as volunteer_router
|
||||
from app.config import get_settings
|
||||
from app.logging_config import configure_logging
|
||||
@@ -72,6 +73,7 @@ app.include_router(shift_router)
|
||||
app.include_router(assignment_router)
|
||||
app.include_router(solver_router)
|
||||
app.include_router(matrix_router)
|
||||
app.include_router(stats_router)
|
||||
app.include_router(notification_router)
|
||||
app.include_router(badge_router)
|
||||
app.include_router(form_router)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user