diff --git a/CLAUDE.md b/CLAUDE.md index 72f613f..ee630de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`). diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 1380d49..d3cbf21 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 # # --------------------------------------------------------------------------- # diff --git a/backend/app/api/solver.py b/backend/app/api/solver.py new file mode 100644 index 0000000..d6d1f5f --- /dev/null +++ b/backend/app/api/solver.py @@ -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), + ) diff --git a/backend/app/main.py b/backend/app/main.py index 6b9aeca..1ddb22e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/solver/__init__.py b/backend/app/solver/__init__.py new file mode 100644 index 0000000..45d38de --- /dev/null +++ b/backend/app/solver/__init__.py @@ -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", +] diff --git a/backend/app/solver/engine.py b/backend/app/solver/engine.py new file mode 100644 index 0000000..8e3a295 --- /dev/null +++ b/backend/app/solver/engine.py @@ -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)) diff --git a/backend/app/solver/loader.py b/backend/app/solver/loader.py new file mode 100644 index 0000000..13569cd --- /dev/null +++ b/backend/app/solver/loader.py @@ -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 + ), + ) diff --git a/backend/app/solver/models.py b/backend/app/solver/models.py new file mode 100644 index 0000000..f0c4737 --- /dev/null +++ b/backend/app/solver/models.py @@ -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) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 05107ad..7ebb6ee 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "openpyxl>=3.1", "pyjwt>=2.9", "bcrypt>=4.1", + "ortools>=9.11", ] [dependency-groups] diff --git a/backend/tests/test_api_solver.py b/backend/tests/test_api_solver.py new file mode 100644 index 0000000..b61d918 --- /dev/null +++ b/backend/tests/test_api_solver.py @@ -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() == [] diff --git a/backend/tests/test_solver_engine.py b/backend/tests/test_solver_engine.py new file mode 100644 index 0000000..d43c1a9 --- /dev/null +++ b/backend/tests/test_solver_engine.py @@ -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",) diff --git a/backend/uv.lock b/backend/uv.lock index 64e3a4d..214ed75 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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"