Solveur de suggestions (OR-Tools CP-SAT) — ne place jamais personne

Nouveau module `backend/app/solver/` (mêmes conventions que
`validation/` : models purs, engine pur testé, loader seul lien avec la
base). Pour chaque créneau encore sous son effectif idéal, calcule une
liste ordonnée de bénévoles candidats — c'est la coordination qui
affecte, dans l'écran habituel ; le solveur fait juste remonter ces
candidats en tête de liste (voir le câblage frontend à suivre).

- Seule contrainte dure : aucun chevauchement de créneaux pour un même
  bénévole (règle SHIFT_OVERLAP), à la fois contre l'existant et entre
  deux suggestions du même calcul. L'effectif idéal est un plafond dur.
- Tout le reste est un terme de l'objectif, pas un blocage : priorité
  forte aux créneaux sous l'effectif minimal, bonus vœu de poste, malus
  disponibilité manquante (réutilise `validation.engine.uncovered_minutes`),
  cumul de 8 h et repos (contre les affectations déjà décidées),
  répartition de la charge.
- `POST /editions/{id}/solver/suggestions` (COORDINATION/référent) :
  recalcule à la demande, ne persiste jamais d'affectation.

Dépendance `ortools`. 18 nouveaux tests (moteur + API), 172 tests au
vert, `ruff` et `alembic check` verts. Testé sur le jeu de démo (120
bénévoles, 100 créneaux) : ~0,3 s.

CLAUDE.md : nouvelle section 4 (solveur), renumérotation 5-10,
compétences retirées de « hors périmètre », journal étape 15.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-11 08:16:17 +02:00
co-authored by Claude Sonnet 5
parent 36d108b47d
commit 803db5e9a9
12 changed files with 818 additions and 11 deletions
+55 -11
View File
@@ -235,7 +235,48 @@ Sémantique figée :
---
## 4. Périmètre
## 4. Solveur de suggestions (OR-Tools CP-SAT)
Module isolé `backend/app/solver/` (mêmes conventions que `validation/` :
`models.py` types purs, `engine.py` fonction pure `solve()`, `loader.py` seul
point de contact avec la base). **Ne place jamais personne** : il calcule,
pour chaque créneau encore sous son effectif idéal, une liste ordonnée de
bénévoles candidats. La coordination affecte elle-même dans l'écran habituel
(grille des besoins) ; l'appli fait simplement remonter ces candidats en tête
de la colonne des choix quand on ouvre le détail d'un poste. Aucune
affectation n'est jamais persistée par cet endpoint.
- `POST /editions/{id}/solver/suggestions` (COORDINATION ou référent) :
recalcule à la demande (rien n'est mis en cache côté serveur) et renvoie
`{ suggestions: [{ shift_id, volunteer_ids }], shifts_considered,
volunteers_considered }`, `volunteer_ids` du meilleur au moins bon.
- **Seule contrainte dure** : aucun chevauchement de créneaux pour un même
bénévole (règle `SHIFT_OVERLAP`) — à la fois contre les affectations déjà
décidées et entre deux créneaux nouvellement suggérés. L'effectif idéal est
un plafond dur (le solveur ne sur-staffe jamais).
- Tout le reste est un terme de l'objectif (maximisation), pas une
contrainte — cohérent avec le moteur de validation, qui reste seul juge au
moment où la coordination confirme une affectation :
- un créneau encore sous son effectif **minimal** prime largement sur tout
le reste (le solveur doit presque toujours proposer quelqu'un pour un
trou urgent, quitte à ce que ce soit un pis-aller signalé ensuite par un
avertissement) ;
- bonus si le poste fait partie des vœux du bénévole
(`VolunteerPositionWish`) ;
- malus proportionnel aux minutes hors des fenêtres de disponibilité
déclarées (réutilise `validation.engine.uncovered_minutes`), au dépassement
du cumul de 8 h ce jour-là, et si le repos avec une affectation déjà
décidée serait `< 8 h 00` ;
- léger malus pour les bénévoles déjà bien chargés (répartition de la
charge).
- Simplification assumée : cumul de 8 h et repos ne sont vérifiés que
contre les affectations **déjà décidées**, pas entre deux suggestions du
même calcul — la validation finale à la confirmation reste seule juge de
ce cas limite.
---
## 5. Périmètre
### Premier incrément (en cours)
@@ -252,15 +293,16 @@ Sémantique figée :
`[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`.
6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours,
quelques trous d'effectif, 2–3 avertissements actifs.
7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours.
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
Solveur OR-Tools · SMS · constructeur de formulaire · pointage sur site ·
SMS · constructeur de formulaire · pointage sur site ·
fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
---
## 5. Arborescence
## 6. Arborescence
```
.
@@ -286,12 +328,13 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
│ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers,
│ │ │ # availability, positions, shifts,
│ │ │ # assignments, matrix, notifications
│ │ │ # assignments, solver, matrix, notifications
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
│ │ ├── models/ # tables SQLModel
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
│ │ ├── i18n/
@@ -306,7 +349,7 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
---
## 6. Commandes
## 7. Commandes
Aucune dépendance locale requise hors Docker.
@@ -326,7 +369,7 @@ Aucune dépendance locale requise hors Docker.
---
## 7. Conventions
## 8. Conventions
- **Code, base, noms de fichiers en anglais.** Le français vit dans les
fichiers de traduction et les commits.
@@ -347,7 +390,7 @@ Aucune dépendance locale requise hors Docker.
---
## 8. Déploiement
## 9. Déploiement
- Dépôt GitHub. VPS avec Docker.
- `.github/workflows/deploy.yml` : sur chaque push `main` → build de l'image
@@ -361,7 +404,7 @@ Aucune dépendance locale requise hors Docker.
---
## 9. Journal des étapes
## 10. Journal des étapes
| Étape | Description | État |
|-------|-------------------------------------------------------|----------|
@@ -379,7 +422,8 @@ Aucune dépendance locale requise hors Docker.
| 12 | Créneaux types (`DayPart` CRUD) + génération auto des créneaux 0/0 à la création d'un poste ; case « à définir » / « sureffectif » sur la grille | à valider |
| 13 | Bénévole : retrait taille de t-shirt / régime ; entité `Artist` (CRUD, par jour) + vœux des bénévoles (artistes, postes) | à valider |
| 14 | Retrait des compétences (`Skill` et règle `MISSING_REQUIRED_SKILL`) ; disponibilités = fenêtres horaires libres par jour (`GET`/`PUT` availability), prises en compte par `OUTSIDE_AVAILABILITY` | à valider |
| 15 | Solveur de suggestions OR-Tools CP-SAT (section 4) : `POST /editions/{id}/solver/suggestions`, candidats remontés en tête de liste dans la grille des besoins — aucune affectation automatique | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : solveur OR-Tools,
SMS, constructeur de formulaire, pointage sur site, IA. Frontend :
squelette + 6 écrans + connexion en place (voir `frontend/`).
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
connexion en place (voir `frontend/`).
+15
View File
@@ -264,6 +264,21 @@ class AssignmentValidateResult(ApiModel):
violations: list[Violation] = Field(default_factory=list)
# --------------------------------------------------------------------------- #
# Solveur (suggestions, CP-SAT) — ne place jamais personne #
# --------------------------------------------------------------------------- #
class SolverSuggestion(ApiModel):
shift_id: uuid.UUID
# Bénévoles suggérés pour ce créneau, meilleur en tête.
volunteer_ids: list[uuid.UUID]
class SolverRunResult(ApiModel):
suggestions: list[SolverSuggestion]
shifts_considered: int
volunteers_considered: int
# --------------------------------------------------------------------------- #
# Matrice poste × créneau #
# --------------------------------------------------------------------------- #
+38
View File
@@ -0,0 +1,38 @@
"""Suggestions du solveur (OR-Tools CP-SAT). Ne persiste rien : la
coordination affecte elle-même, dans l'écran habituel, en tenant compte des
bénévoles remontés en tête de liste."""
import uuid
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.api.deps import get_edition
from app.api.schemas import SolverRunResult, SolverSuggestion
from app.auth.deps import require_staff
from app.db import get_session
from app.models import Account, Edition
from app.solver import build_solver_input, solve
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
@router.post("/suggestions", response_model=SolverRunResult)
def run_solver(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> SolverRunResult:
data = build_solver_input(session=session, edition=edition)
solution = solve(data)
return SolverRunResult(
suggestions=[
SolverSuggestion(
shift_id=uuid.UUID(s.shift_id),
volunteer_ids=[uuid.UUID(v) for v in s.volunteer_ids],
)
for s in solution.suggestions
],
shifts_considered=len(data.shifts),
volunteers_considered=len(data.volunteers),
)
+2
View File
@@ -14,6 +14,7 @@ from app.api.matrix import router as matrix_router
from app.api.notifications import router as notification_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.volunteers import router as volunteer_router
from app.config import get_settings
from app.logging_config import configure_logging
@@ -44,5 +45,6 @@ app.include_router(availability_router)
app.include_router(position_router)
app.include_router(shift_router)
app.include_router(assignment_router)
app.include_router(solver_router)
app.include_router(matrix_router)
app.include_router(notification_router)
+22
View File
@@ -0,0 +1,22 @@
"""Solveur de suggestions (OR-Tools CP-SAT) — voir `engine.py` pour le détail
des règles. Ne place jamais personne : seule la coordination affecte."""
from app.solver.engine import solve
from app.solver.loader import build_solver_input
from app.solver.models import (
ShiftSuggestion,
SolverInput,
SolverShift,
SolverSolution,
SolverVolunteer,
)
__all__ = [
"ShiftSuggestion",
"SolverInput",
"SolverShift",
"SolverSolution",
"SolverVolunteer",
"build_solver_input",
"solve",
]
+190
View File
@@ -0,0 +1,190 @@
"""Solveur de suggestions (OR-Tools CP-SAT) — cœur du produit, second moteur.
Ne place jamais personne : il propose, pour chaque créneau encore sous son
effectif idéal, une liste ordonnée de bénévoles candidats. C'est la
coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
contente de faire remonter ces candidats en tête de liste).
Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, cumul de
8 h, repos, vœu de poste, équité de charge) est un terme de l'objectif, pas
une contrainte — comme le moteur de validation, qui reste seul juge au moment
où la coordination confirme une affectation.
"""
import datetime as dt
from ortools.sat.python import cp_model
from app.solver.models import (
ShiftSuggestion,
SolverInput,
SolverShift,
SolverSolution,
SolverVolunteer,
)
from app.validation.engine import uncovered_minutes
from app.validation.models import DayPartWindow, ShiftInfo
_EIGHT_HOURS_MIN = 8 * 60
# Poids de l'objectif (unités arbitraires, cf. docstring de `_score`).
# L'écart à un effectif minimal non atteint prime sur tout le reste : le
# solveur doit toujours proposer quelqu'un pour un créneau sous-staffé, quitte
# à ce que la disponibilité soit imparfaite (avertissement, pas un blocage —
# la validation reste seule juge à la confirmation). Au-delà du minimum, un
# candidat mal assorti peut légitimement ne pas être suggéré du tout.
_URGENT_BASE = 1000 # créneau encore sous son effectif minimal
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
_WISH_BONUS = 30
_AVAIL_PENALTY_PER_HALF_HOUR = 5
_DAILY_OVERAGE_PENALTY_PER_MIN = 2
_REST_PENALTY = 60
_FAIRNESS_PENALTY_PER_HOUR = 2
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
def _day_boundary(day_parts: tuple[DayPartWindow, ...]) -> dt.time:
if not day_parts:
return dt.time(0, 0)
return min(day_parts, key=lambda part: part.order).start_time
def _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date:
if moment.time() >= boundary:
return moment.date()
return moment.date() - dt.timedelta(days=1)
def _daily_overage_minutes(
volunteer: SolverVolunteer, shift: SolverShift, boundary: dt.time
) -> int:
"""Minutes qui dépasseraient les 8 h de la journée du créneau, en ne
comptant que les affectations déjà décidées (pas les autres suggestions
du même calcul — ce cas limite reste sous la vigilance du moteur de
validation au moment de la confirmation)."""
key = _day_key(shift.starts_at, boundary)
total = shift.ends_at - shift.starts_at
for other in volunteer.existing_shifts:
if _day_key(other.starts_at, boundary) == key:
total += other.ends_at - other.starts_at
minutes = int(total.total_seconds() // 60)
return max(0, minutes - _EIGHT_HOURS_MIN)
def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
candidate = shift.shift_info
for other in volunteer.existing_shifts:
if _overlaps(candidate, other):
continue
if other.ends_at <= candidate.starts_at:
gap = candidate.starts_at - other.ends_at
elif candidate.ends_at <= other.starts_at:
gap = other.starts_at - candidate.ends_at
else:
continue
if gap < dt.timedelta(minutes=_EIGHT_HOURS_MIN):
return True
return False
def _score(
volunteer: SolverVolunteer,
shift: SolverShift,
day_boundary: dt.time,
) -> int:
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
Purement additif (aucune interaction entre deux affectations proposées),
ce qui garde le modèle CP-SAT linéaire simple. Cumul de 8 h et repos ne
sont vérifiés que contre les affectations déjà décidées, pas entre deux
suggestions du même calcul : la validation finale (à la confirmation)
reste seule juge en cas de cas limite entre deux suggestions.
"""
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
if shift.position_id in volunteer.wished_position_ids:
score += _WISH_BONUS
missed = uncovered_minutes(shift.shift_info, volunteer.availability)
score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR
score -= _DAILY_OVERAGE_PENALTY_PER_MIN * _daily_overage_minutes(
volunteer, shift, day_boundary
)
if _has_insufficient_rest(volunteer, shift):
score -= _REST_PENALTY
score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60
return score
def solve(
data: SolverInput,
*,
time_limit_seconds: float = 8.0,
seed: int = 42,
) -> SolverSolution:
"""Calcule les suggestions. Fonction pure : ne touche jamais la base."""
boundary = _day_boundary(data.day_parts)
model = cp_model.CpModel()
variables: dict[tuple[int, int], cp_model.IntVar] = {}
scores: dict[tuple[int, int], int] = {}
for si, shift in enumerate(data.shifts):
gap = shift.ideal_staff - shift.already_assigned
if gap <= 0:
continue
for vi, volunteer in enumerate(data.volunteers):
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts):
continue
key = (vi, si)
variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
scores[key] = _score(volunteer, shift, boundary)
if not variables:
return SolverSolution()
# Effectif idéal comme plafond dur — le solveur ne sur-staffe jamais.
for si, shift in enumerate(data.shifts):
gap = shift.ideal_staff - shift.already_assigned
n_volunteers = len(data.volunteers)
shift_vars = [variables[vi, si] for vi in range(n_volunteers) if (vi, si) in variables]
if shift_vars and gap > 0:
model.Add(sum(shift_vars) <= gap)
# Aucun chevauchement entre deux créneaux nouvellement suggérés au même
# bénévole (règle `SHIFT_OVERLAP`, bloquante).
for vi in range(len(data.volunteers)):
my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables]
for a in range(len(my_shifts)):
for b in range(a + 1, len(my_shifts)):
si_a, si_b = my_shifts[a], my_shifts[b]
if _overlaps(data.shifts[si_a].shift_info, data.shifts[si_b].shift_info):
model.Add(variables[vi, si_a] + variables[vi, si_b] <= 1)
model.Maximize(sum(score * variables[key] for key, score in scores.items()))
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = time_limit_seconds
solver.parameters.num_search_workers = 1
solver.parameters.random_seed = seed
status = solver.Solve(model)
chosen: dict[int, list[int]] = {}
if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
for (vi, si), var in variables.items():
if solver.Value(var):
chosen.setdefault(si, []).append(vi)
suggestions: list[ShiftSuggestion] = []
for si, shift in enumerate(data.shifts):
picked = sorted(chosen.get(si, []), key=lambda vi: -scores[(vi, si)])
if picked:
suggestions.append(
ShiftSuggestion(
shift_id=shift.id,
volunteer_ids=tuple(data.volunteers[vi].id for vi in picked),
)
)
return SolverSolution(suggestions=tuple(suggestions))
+97
View File
@@ -0,0 +1,97 @@
"""Construit un `SolverInput` depuis la base pour une édition. Couche de
liaison : c'est le seul endroit du module `solver` qui connaît la base."""
from collections import defaultdict
from sqlmodel import Session, select
from app.models import (
Assignment,
Availability,
DayPart,
Edition,
Shift,
Volunteer,
VolunteerPositionWish,
)
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.models import AvailabilityWindow, DayPartWindow, ShiftInfo
def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
shifts = list(session.exec(select(Shift).where(Shift.edition_id == edition.id)))
assignments = list(
session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
)
shifts_by_id = {s.id: s for s in shifts}
assigned_count: dict = defaultdict(int)
shifts_by_volunteer: dict = defaultdict(list)
for a in assignments:
assigned_count[a.shift_id] += 1
shift = shifts_by_id.get(a.shift_id)
if shift is not None:
shifts_by_volunteer[a.volunteer_id].append(shift)
solver_shifts = tuple(
SolverShift(
id=str(s.id),
position_id=str(s.position_id),
starts_at=s.starts_at,
ends_at=s.ends_at,
min_staff=s.min_staff,
ideal_staff=s.ideal_staff,
already_assigned=assigned_count.get(s.id, 0),
)
for s in shifts
if assigned_count.get(s.id, 0) < s.ideal_staff
)
availabilities: dict = defaultdict(list)
for a in session.exec(
select(Availability).where(Availability.edition_id == edition.id)
):
availabilities[a.volunteer_id].append(
AvailabilityWindow(date=a.date, start_time=a.start_time, end_time=a.end_time)
)
wishes: dict = defaultdict(set)
for w in session.exec(
select(VolunteerPositionWish).join(
Volunteer, Volunteer.id == VolunteerPositionWish.volunteer_id
).where(Volunteer.edition_id == edition.id)
):
wishes[w.volunteer_id].add(str(w.position_id))
volunteers = list(
session.exec(select(Volunteer).where(Volunteer.edition_id == edition.id))
)
solver_volunteers = tuple(
SolverVolunteer(
id=str(v.id),
availability=tuple(availabilities.get(v.id, ())),
wished_position_ids=frozenset(wishes.get(v.id, ())),
existing_shifts=tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
for s in shifts_by_volunteer.get(v.id, ())
),
existing_minutes=sum(
int((s.ends_at - s.starts_at).total_seconds() // 60)
for s in shifts_by_volunteer.get(v.id, ())
),
)
for v in volunteers
)
return SolverInput(
shifts=solver_shifts,
volunteers=solver_volunteers,
day_parts=tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order)
for dp in day_parts
),
)
+60
View File
@@ -0,0 +1,60 @@
"""Types d'entrée/sortie du solveur — suggestions, pas d'affectation automatique.
Le solveur ne connaît que ces structures simples (jamais les modèles
SQLModel), comme le moteur de validation dont il réutilise les briques
(`app.validation.models`, `app.validation.engine.uncovered_minutes`).
"""
import datetime as dt
from dataclasses import dataclass, field
from app.validation.models import AvailabilityWindow, DayPartWindow, ShiftInfo
@dataclass(frozen=True)
class SolverShift:
"""Un créneau à compléter (`assigned < ideal_staff`)."""
id: str
position_id: str
starts_at: dt.datetime
ends_at: dt.datetime
min_staff: int
ideal_staff: int
already_assigned: int
@property
def shift_info(self) -> ShiftInfo:
return ShiftInfo(id=self.id, starts_at=self.starts_at, ends_at=self.ends_at)
@dataclass(frozen=True)
class SolverVolunteer:
id: str
availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset()
# Créneaux déjà occupés (affectations existantes, décisions figées) :
# comptent pour chevauchement, cumul journalier, repos.
existing_shifts: tuple[ShiftInfo, ...] = ()
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
existing_minutes: int = 0
@dataclass(frozen=True)
class SolverInput:
shifts: tuple[SolverShift, ...] = field(default_factory=tuple)
volunteers: tuple[SolverVolunteer, ...] = field(default_factory=tuple)
day_parts: tuple[DayPartWindow, ...] = field(default_factory=tuple)
@dataclass(frozen=True)
class ShiftSuggestion:
"""Bénévoles suggérés pour un créneau, meilleur en tête."""
shift_id: str
volunteer_ids: tuple[str, ...]
@dataclass(frozen=True)
class SolverSolution:
suggestions: tuple[ShiftSuggestion, ...] = field(default_factory=tuple)
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"openpyxl>=3.1",
"pyjwt>=2.9",
"bcrypt>=4.1",
"ortools>=9.11",
]
[dependency-groups]
+68
View File
@@ -0,0 +1,68 @@
import uuid
from app.models import Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def _url(edition, suffix=""):
return f"/editions/{edition.id}/solver{suffix}"
def test_suggests_a_free_volunteer_for_an_understaffed_shift(client, edition, session):
position = make_position(session, edition)
shift = make_shift(
session, edition, position,
start=dt_at(10, 14), end=dt_at(10, 18), min_staff=1, ideal_staff=1,
)
volunteer = make_volunteer(session, edition, email="v@ex.fr")
resp = client.post(_url(edition, "/suggestions"))
assert resp.status_code == 200
body = resp.json()
assert body["shifts_considered"] == 1
assert body["volunteers_considered"] == 1
suggestion = next(s for s in body["suggestions"] if s["shift_id"] == str(shift.id))
assert suggestion["volunteer_ids"] == [str(volunteer.id)]
def test_fully_staffed_shift_has_no_suggestion(client, edition, session):
from app.models import Assignment
position = make_position(session, edition)
shift = make_shift(
session, edition, position,
start=dt_at(10, 14), end=dt_at(10, 18), min_staff=1, ideal_staff=1,
)
volunteer = make_volunteer(session, edition, email="v@ex.fr")
session.add(
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
)
session.commit()
resp = client.post(_url(edition, "/suggestions"))
assert resp.status_code == 200
shift_ids = {s["shift_id"] for s in resp.json()["suggestions"]}
assert str(shift.id) not in shift_ids
def test_unknown_edition_is_404(client):
assert client.post(f"/editions/{uuid.uuid4()}/solver/suggestions").status_code == 404
def test_volunteer_role_forbidden(edition, session, make_account, as_account):
account = make_account(Role.VOLUNTEER, email="v@e.fr")
api = as_account(account)
assert api.post(_url(edition, "/suggestions")).status_code == 403
def test_does_not_persist_any_assignment(client, edition, session):
from sqlmodel import select
from app.models import Assignment
position = make_position(session, edition)
make_shift(session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18))
make_volunteer(session, edition, email="v@ex.fr")
client.post(_url(edition, "/suggestions"))
assert session.exec(select(Assignment)).all() == []
+148
View File
@@ -0,0 +1,148 @@
"""Tests du solveur (suggestions, jamais d'affectation automatique)."""
import datetime as dt
from app.solver.engine import solve
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.models import AvailabilityWindow, ShiftInfo
def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
return dt.datetime(2026, 7, day, hour, minute)
def _shift(
sid: str = "s1",
*,
position_id: str = "p1",
start: dt.datetime | None = None,
end: dt.datetime | None = None,
min_staff=1,
ideal_staff=1,
already_assigned=0,
) -> SolverShift:
return SolverShift(
id=sid,
position_id=position_id,
starts_at=start or _dt(10, 14),
ends_at=end or _dt(10, 18),
min_staff=min_staff,
ideal_staff=ideal_staff,
already_assigned=already_assigned,
)
def _volunteer(
vid: str,
*,
availability=(),
wished_position_ids=frozenset(),
existing_shifts=(),
existing_minutes=0,
) -> SolverVolunteer:
return SolverVolunteer(
id=vid,
availability=tuple(availability),
wished_position_ids=frozenset(wished_position_ids),
existing_shifts=tuple(existing_shifts),
existing_minutes=existing_minutes,
)
def _full_day(day: int) -> AvailabilityWindow:
return AvailabilityWindow(dt.date(2026, 7, day), dt.time(6, 0), dt.time(4, 0))
def _by_shift(solution, shift_id: str) -> tuple[str, ...]:
for s in solution.suggestions:
if s.shift_id == shift_id:
return s.volunteer_ids
return ()
def test_no_variables_returns_empty_solution():
data = SolverInput(shifts=(), volunteers=())
assert solve(data).suggestions == ()
def test_fully_staffed_shift_gets_no_suggestion():
shift = _shift(ideal_staff=1, already_assigned=1)
volunteer = _volunteer("v1", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
assert solution.suggestions == ()
def test_picks_the_only_free_volunteer_over_a_busy_one():
shift = _shift()
busy = _volunteer(
"busy",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
)
free = _volunteer("free", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free)))
assert _by_shift(solution, "s1") == ("free",)
def test_does_not_exceed_ideal_staff_cap():
shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place
a = _volunteer("a", availability=[_full_day(10)])
b = _volunteer("b", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b)))
assert len(_by_shift(solution, "s1")) == 1
def test_prefers_volunteer_with_matching_position_wish():
shift = _shift(position_id="regie")
plain = _volunteer("plain", availability=[_full_day(10)])
wisher = _volunteer(
"wisher", availability=[_full_day(10)], wished_position_ids={"regie"}
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher)))
assert _by_shift(solution, "s1") == ("wisher",)
def test_prefers_fully_available_volunteer_over_partially_available():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
full = _volunteer("full", availability=[_full_day(10)])
partial = _volunteer(
"partial",
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
assert _by_shift(solution, "s1") == ("full",)
def test_ranks_both_candidates_when_two_slots_are_open():
shift = _shift(ideal_staff=2)
full = _volunteer("full", availability=[_full_day(10)])
partial = _volunteer(
"partial",
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
assert _by_shift(solution, "s1") == ("full", "partial")
def test_never_double_books_a_volunteer_across_two_new_suggestions():
# Deux créneaux qui se chevauchent, un seul bénévole disponible pour les
# deux : le solveur ne le propose qu'une fois.
shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18))
shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20))
only = _volunteer("only", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,)))
picked_a = _by_shift(solution, "a")
picked_b = _by_shift(solution, "b")
assert len(picked_a) + len(picked_b) == 1
def test_avoids_volunteer_who_would_exceed_eight_hours_that_day():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) # 4 h
tired = _volunteer(
"tired",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 12))], # 6 h déjà
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
assert _by_shift(solution, "s1") == ("fresh",)
+122
View File
@@ -1,5 +1,19 @@
version = 1
requires-python = "==3.12.*"
resolution-markers = [
"sys_platform == 'win32'",
"sys_platform == 'emscripten'",
"sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[[package]]
name = "absl-py"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/4f/d79676ab82f2e42fc3611618139f13a9c4c31d0cff4b486982047679a802/absl_py-2.5.0.tar.gz", hash = "sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f", size = 118119 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/0a/a10b45aab35b175aded078a462dc8d0c698f5b13946e7cb0869097b78bb6/absl_py-2.5.0-py3-none-any.whl", hash = "sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba", size = 137410 },
]
[[package]]
name = "alembic"
@@ -93,6 +107,7 @@ dependencies = [
{ name = "bcrypt" },
{ name = "fastapi" },
{ name = "openpyxl" },
{ name = "ortools" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
@@ -115,6 +130,7 @@ requires-dist = [
{ name = "bcrypt", specifier = ">=4.1" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "openpyxl", specifier = ">=3.1" },
{ name = "ortools", specifier = ">=9.11" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pyjwt", specifier = ">=2.9" },
@@ -286,6 +302,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550 },
]
[[package]]
name = "immutabledict"
version = "4.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/e6/718471048fea0366c3e3d1df3acfd914ca66d571cdffcf6d37bbcd725708/immutabledict-4.3.1.tar.gz", hash = "sha256:f844a669106cfdc73f47b1a9da003782fb17dc955a54c80972e0d93d1c63c514", size = 7806 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/ce/f9018bf69ae91b273b6391a095e7c93fa5e1617f25b6ba81ad4b20c9df10/immutabledict-4.3.1-py3-none-any.whl", hash = "sha256:c9facdc0ff30fdb8e35bd16532026cac472a549e182c94fa201b51b25e4bf7bf", size = 5000 },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -326,6 +351,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 },
]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982 },
{ url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327 },
{ url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405 },
{ url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213 },
{ url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872 },
{ url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410 },
{ url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975 },
{ url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479 },
{ url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378 },
{ url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828 },
{ url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432 },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
@@ -338,6 +382,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 },
]
[[package]]
name = "ortools"
version = "9.15.6755"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "absl-py" },
{ name = "immutabledict" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "protobuf" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/fc/9fa53f1a13710e6183df4d00fe4988c79a55b501e282645d49f1e250437f/ortools-9.15.6755-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:ae1c6e1fd844b4d756b22eb6c0ed574ea4342ee206d807c4f903039e748228fa", size = 23926322 },
{ url = "https://files.pythonhosted.org/packages/f1/b6/7e6618ef7a88e8eb706a8a876806b4d336f1bef8c574f8a02d2da3e483ef/ortools-9.15.6755-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e16686c2b457fa6242c474ab890ee1712347ab53678e0d2fab307ae03e97a4b", size = 21912940 },
{ url = "https://files.pythonhosted.org/packages/86/a9/37cb31fc5ffbec2650ebb0d2538a83842b5693a788a0ec6057559dab1169/ortools-9.15.6755-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3cd6bec0a2e00e3891a53e3b436f45a1000269f302085572f49e9856b7f8eaf0", size = 27646561 },
{ url = "https://files.pythonhosted.org/packages/49/0f/6d6d722102a0ceccf4a5038e2bc91d023da84a6dba98482a4634df3d27ab/ortools-9.15.6755-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:033836c0eb33bc72697a299e0caedbb25fc9d1cee0b13832d69cb30405f57b3e", size = 29838435 },
{ url = "https://files.pythonhosted.org/packages/83/a2/5aaf12e34bcd47ae16e70ae81b5c7fbc209da0615c0b79a93c9a0b1cda02/ortools-9.15.6755-cp312-cp312-win_amd64.whl", hash = "sha256:487796301fd9dad55f9cf21f9313c834697f74306d1a59f002e152862f8eb1b5", size = 23883161 },
]
[[package]]
name = "packaging"
version = "26.3"
@@ -347,6 +411,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 },
]
[[package]]
name = "pandas"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717 },
{ url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095 },
{ url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458 },
{ url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091 },
{ url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501 },
{ url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559 },
{ url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652 },
{ url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691 },
{ url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -356,6 +442,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "protobuf"
version = "6.33.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739 },
{ url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089 },
{ url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737 },
{ url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610 },
{ url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381 },
{ url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436 },
{ url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656 },
]
[[package]]
name = "psycopg"
version = "3.3.5"
@@ -503,6 +604,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
@@ -564,6 +677,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850 },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"