Retrait des disponibilités et des limites de temps dans la journée
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s

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>
This commit is contained in:
AdamThomas
2026-10-05 21:29:58 +02:00
co-authored by Claude Opus 5.5
parent f671bb85bf
commit d722a81a87
40 changed files with 213 additions and 1845 deletions
-132
View File
@@ -1,132 +0,0 @@
"""Disponibilités déclarées par les bénévoles.
Une disponibilité = « le <date>, je peux de <start_time> à <end_time> ».
Fenêtre horaire libre (pas liée aux créneaux types) ; plusieurs par jour
possibles. `end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
Ces fenêtres sont prises en compte par le moteur de validation (règle
`OUTSIDE_AVAILABILITY`) au moment d'affecter un bénévole à un créneau.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete
from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import AvailabilityRead, AvailabilitySet
from app.auth.deps import (
current_account_in_edition,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.models import Account, Availability, Edition, Role, Volunteer
router = APIRouter(prefix="/editions/{edition_id}", tags=["availability"])
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
if account.role in (Role.COORDINATOR, Role.LEAD):
return
if account.volunteer_id == volunteer_id:
return
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
def _windows(session: Session, volunteer_id: uuid.UUID) -> list[Availability]:
return list(
session.exec(
select(Availability)
.where(Availability.volunteer_id == volunteer_id)
.order_by(Availability.date, Availability.start_time)
)
)
@router.get(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def list_availability(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> list[Availability]:
_ensure_can_read(account, volunteer_id)
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
return _windows(session, volunteer_id)
@router.put(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def replace_availability(
volunteer_id: uuid.UUID,
payload: AvailabilitySet,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[Availability]:
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
for w in payload.windows:
if not edition.start_date <= w.date <= edition.end_date:
raise HTTPException(
status_code=422,
detail={
"code": "availability.date_outside_edition",
"params": {"date": w.date.isoformat()},
},
)
if w.start_time == w.end_time:
raise HTTPException(
status_code=422,
detail={"code": "availability.empty_window"},
)
session.execute(
delete(Availability).where(Availability.volunteer_id == volunteer_id)
)
# Dé-doublonne (date, start, end) : la contrainte d'unicité l'imposerait.
seen: set[tuple] = set()
for w in payload.windows:
key = (w.date, w.start_time, w.end_time)
if key in seen:
continue
seen.add(key)
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer_id,
date=w.date,
start_time=w.start_time,
end_time=w.end_time,
)
)
session.commit()
return _windows(session, volunteer_id)
@router.get("/availability", response_model=list[AvailabilityRead])
def list_edition_availability(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[Availability]:
"""Toutes les disponibilités de l'édition (pour la grille des besoins)."""
return list(
session.exec(
select(Availability)
.where(Availability.edition_id == edition.id)
.order_by(
Availability.volunteer_id,
Availability.date,
Availability.start_time,
)
)
)
+1 -32
View File
@@ -254,33 +254,6 @@ class DayPartUpdate(ApiModel):
order: int | None = None
# --------------------------------------------------------------------------- #
# Availability (disponibilités déclarées par le bénévole) #
# --------------------------------------------------------------------------- #
class AvailabilityWindowIn(ApiModel):
date: dt.date
start_time: dt.time
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
end_time: dt.time
class AvailabilitySet(ApiModel):
"""Remplace l'intégralité des disponibilités d'un bénévole."""
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
class AvailabilityRead(ApiModel):
id: uuid.UUID
edition_id: uuid.UUID
volunteer_id: uuid.UUID
date: dt.date
start_time: dt.time
end_time: dt.time
created_at: dt.datetime
updated_at: dt.datetime
# --------------------------------------------------------------------------- #
# Assignment #
# --------------------------------------------------------------------------- #
@@ -714,15 +687,12 @@ class StatsArtistWish(ApiModel):
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."""
× personnes demandées (effectif minimal / idéal), 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
@@ -745,7 +715,6 @@ class StatsNeedPosition(ApiModel):
class StatsNeeds(ApiModel):
by_day: list[StatsNeedDay]
by_position: list[StatsNeedPosition]
volunteers_with_availability: int
volunteers_with_assignment: int
+1 -15
View File
@@ -30,7 +30,6 @@ from app.models import (
Account,
Artist,
Assignment,
Availability,
DayPart,
Edition,
FeePaymentStatus,
@@ -126,9 +125,6 @@ def get_stats(
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)
@@ -203,8 +199,7 @@ def get_stats(
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,
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "assigned_hours": 0.0,
}
)
for s in shifts:
@@ -219,12 +214,6 @@ def get_stats(
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)
@@ -239,8 +228,6 @@ def get_stats(
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())
@@ -260,7 +247,6 @@ def get_stats(
)
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}),
)