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