Retrait des compétences ; disponibilités en fenêtres horaires libres

Compétences supprimées de bout en bout :
- tables `skill`, `volunteer_skill`, `shift_required_skill` ;
- `Volunteer.skill_codes`, `Shift.required_skill_codes`, colonne `skills`
  de l'import, règle de validation `MISSING_REQUIRED_SKILL`, jeu de démo.

Disponibilités repensées : `Availability` passe de (`day_part_id`,
`status` tri-état) à une **fenêtre horaire libre** (`date`, `start_time`,
`end_time` ; `end <= start` ⇒ passe minuit), plusieurs par jour.
- Nouveaux endpoints `GET`/`PUT /editions/{id}/volunteers/{vid}/availability`
  (PUT = remplacement complet, COORDINATION) et
  `GET /editions/{id}/availability` (toute l'édition, pour la grille).
- `OUTSIDE_AVAILABILITY` (toujours WARNING) : compare le créneau à l'union
  des fenêtres du bénévole ce jour-là ; renvoie `date` + `uncovered_minutes`.
  `DayPart` ne sert plus qu'à la borne de « journée » du cumul 8 h.
- L'import ne gère plus les disponibilités (fenêtres saisies dans l'app) :
  `values.py` supprimé, mapping réduit à nom/email/téléphone/commentaire.

Migration 0007 (recrée `availability`). `AvailabilityStatus` supprimé.
158 tests, `alembic check` et `ruff` au vert. Front à suivre.

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-10 22:46:45 +02:00
co-authored by Claude Sonnet 5
parent 81609eecc5
commit be51770b51
36 changed files with 800 additions and 1199 deletions
-23
View File
@@ -4,7 +4,6 @@ Le rapport ne contient que des **codes** de traduction + des paramètres
structurés, jamais de phrase (voir `locales/fr.json`).
"""
import datetime as dt
import uuid
from typing import Literal
@@ -20,21 +19,11 @@ class ColumnMapping(BaseModel):
email: str | None = None
phone: str | None = None
comment: str | None = None
skills: str | None = None
class AvailabilityColumnMapping(BaseModel):
header: str
date: dt.date
day_part_code: str
class ImportMapping(BaseModel):
dry_run: bool = True
columns: ColumnMapping
availability_columns: list[AvailabilityColumnMapping] = Field(
default_factory=list
)
class Issue(BaseModel):
@@ -57,8 +46,6 @@ class ImportReport(BaseModel):
created: int = 0
updated: int = 0
skipped: int = 0
availabilities_written: int = 0
skills_created: list[str] = Field(default_factory=list)
rows: list[RowReport] = Field(default_factory=list)
@@ -67,14 +54,6 @@ class PreviewRow(BaseModel):
values: dict[str, str]
class DayPartInfo(BaseModel):
code: str
label: str
start_time: dt.time
end_time: dt.time
order: int
class AnalyzeResponse(BaseModel):
filename: str
format: Literal["csv", "xlsx"]
@@ -82,5 +61,3 @@ class AnalyzeResponse(BaseModel):
row_count: int
preview: list[PreviewRow]
suggested_mapping: dict[str, str]
edition_dates: list[dt.date]
day_parts: list[DayPartInfo]
+9 -153
View File
@@ -1,4 +1,8 @@
"""Cœur de l'import : suggestion de mapping, validation, exécution."""
"""Cœur de l'import : suggestion de mapping, validation, exécution.
L'import ne porte que l'identité des bénévoles (nom, email, téléphone,
commentaire). Les disponibilités se déclarent dans l'application.
"""
import re
@@ -11,16 +15,8 @@ from app.importing.schemas import (
Issue,
RowReport,
)
from app.importing.text import normalize, slug_code, split_skills
from app.importing.values import parse_availability_cell
from app.models import (
Availability,
DayPart,
Edition,
Skill,
Volunteer,
VolunteerSkill,
)
from app.importing.text import normalize
from app.models import Edition, Volunteer
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@@ -30,7 +26,6 @@ _FIELD_HINTS: dict[str, tuple[str, ...]] = {
"last_name": ("nom", "last name", "lastname", "surname", "famille"),
"phone": ("tel", "phone", "portable", "mobile", "gsm"),
"comment": ("commentaire", "remarque", "note", "precision"),
"skills": ("competence", "skill", "habilitation", "brevet", "diplome"),
}
@@ -50,10 +45,7 @@ def suggest_mapping(headers: list[str]) -> dict[str, str]:
def validate_mapping(
table: ParsedTable,
mapping: ImportMapping,
edition: Edition,
day_parts: list[DayPart],
table: ParsedTable, mapping: ImportMapping
) -> list[Issue]:
"""Contrôles préalables. Retourne une liste vide si le mapping est correct."""
errors: list[Issue] = []
@@ -80,7 +72,7 @@ def validate_mapping(
)
)
for field in ("email", "phone", "comment", "skills"):
for field in ("email", "phone", "comment"):
header = getattr(columns, field)
if header and header not in headers:
errors.append(
@@ -91,50 +83,6 @@ def validate_mapping(
params={"column": header},
)
)
valid_codes = {dp.code for dp in day_parts}
seen: set[str] = set()
for column in mapping.availability_columns:
if column.header not in headers:
errors.append(
Issue(
code="import.mapping.column_not_found",
severity="ERROR",
field="availability",
params={"column": column.header},
)
)
if column.header in seen:
errors.append(
Issue(
code="import.mapping.duplicate_availability_column",
severity="ERROR",
params={"column": column.header},
)
)
seen.add(column.header)
if column.day_part_code not in valid_codes:
errors.append(
Issue(
code="import.mapping.unknown_day_part",
severity="ERROR",
params={
"column": column.header,
"day_part_code": column.day_part_code,
},
)
)
if not edition.start_date <= column.date <= edition.end_date:
errors.append(
Issue(
code="import.mapping.date_outside_edition",
severity="ERROR",
params={
"column": column.header,
"date": column.date.isoformat(),
},
)
)
return errors
@@ -150,12 +98,6 @@ def run_import(
Le mapping est supposé déjà validé par `validate_mapping`.
"""
columns = mapping.columns
day_part_by_code = {
dp.code: dp
for dp in session.exec(
select(DayPart).where(DayPart.edition_id == edition.id)
)
}
existing = list(
session.exec(
@@ -169,14 +111,6 @@ def run_import(
key = (normalize(volunteer.last_name), normalize(volunteer.first_name))
by_name.setdefault(key, volunteer)
skills_by_code = {
s.code: s
for s in session.exec(
select(Skill).where(Skill.edition_id == edition.id)
)
}
created_skills: set[str] = set()
report = ImportReport(dry_run=mapping.dry_run, total_rows=len(table.rows))
email_seen: dict[str, int] = {}
name_seen: dict[tuple[str, str], int] = {}
@@ -284,82 +218,6 @@ def run_import(
session.flush()
if columns.skills:
linked = set(
session.exec(
select(VolunteerSkill.skill_id).where(
VolunteerSkill.volunteer_id == volunteer.id
)
)
)
for token in split_skills(row.get(columns.skills) or ""):
code = slug_code(token)
if not code:
continue
skill = skills_by_code.get(code)
if skill is None:
skill = Skill(
edition_id=edition.id, code=code, label=token.strip()
)
session.add(skill)
session.flush()
skills_by_code[code] = skill
created_skills.add(code)
entry.issues.append(
Issue(
code="import.row.skill_created",
severity="WARNING",
params={"code": code, "label": skill.label},
)
)
if skill.id not in linked:
session.add(
VolunteerSkill(
volunteer_id=volunteer.id, skill_id=skill.id
)
)
linked.add(skill.id)
for column in mapping.availability_columns:
status, recognized = parse_availability_cell(
row.get(column.header, "") or ""
)
if not recognized:
entry.issues.append(
Issue(
code="import.row.unknown_availability_value",
severity="WARNING",
field=column.header,
params={
"column": column.header,
"value": (row.get(column.header, "") or "").strip(),
},
)
)
continue
if status is None:
continue
day_part = day_part_by_code[column.day_part_code]
current = session.exec(
select(Availability)
.where(Availability.volunteer_id == volunteer.id)
.where(Availability.date == column.date)
.where(Availability.day_part_id == day_part.id)
).first()
if current is not None:
current.status = status
else:
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=column.date,
day_part_id=day_part.id,
status=status,
)
)
report.availabilities_written += 1
entry.volunteer_id = volunteer.id
report.rows.append(entry)
if entry.action == "CREATED":
@@ -367,8 +225,6 @@ def run_import(
else:
report.updated += 1
report.skills_created = sorted(created_skills)
if mapping.dry_run:
session.rollback()
else:
+3 -8
View File
@@ -1,9 +1,8 @@
"""Normalisation de texte partagée par l'import (accents, casse, découpage)."""
"""Normalisation de texte partagée par l'import (accents, casse)."""
import re
import unicodedata
_SKILL_SEPARATORS = re.compile(r"[;,/\n]+")
_MULTI_UNDERSCORE = re.compile(r"_+")
@@ -15,12 +14,8 @@ def normalize(value: str) -> str:
def slug_code(value: str) -> str:
"""Code stable pour une compétence : MAJUSCULES, séparateurs en `_`."""
"""Code stable (MAJUSCULES, séparateurs en `_`) — utilisé pour les
créneaux types (`DayPart.code`)."""
base = normalize(value).upper()
chars = [c if c.isalnum() else "_" for c in base]
return _MULTI_UNDERSCORE.sub("_", "".join(chars)).strip("_")
def split_skills(value: str) -> list[str]:
parts = (p.strip() for p in _SKILL_SEPARATORS.split(value or ""))
return [p for p in parts if p]
-38
View File
@@ -1,38 +0,0 @@
"""Interprétation des valeurs de cellule : disponibilités."""
from app.importing.text import normalize
from app.models import AvailabilityStatus
_AVAILABLE = {
"x", "o", "oui", "y", "yes", "ok", "1", "vrai", "true",
"dispo", "disponible", "present", "presente",
"matin", "aprem", "apres-midi", "apres midi", "midi", "soir", "soiree",
"journee", "jour",
}
_IF_NEEDED = {
"si besoin", "sib", "peut-etre", "peut etre", "?", "2",
"backup", "reserve", "au besoin",
}
_UNAVAILABLE = {
"non", "n", "no", "0", "faux", "false", "indispo", "indisponible", "absent",
"-", "x-",
}
_EMPTY = {"", "na", "n/a", "nc"}
def parse_availability_cell(value: str) -> tuple[AvailabilityStatus | None, bool]:
"""(statut, reconnu).
- statut `None` + reconnu `True` → cellule vide, pas de déclaration.
- statut `None` + reconnu `False` → valeur non comprise, à signaler.
"""
norm = normalize(value)
if norm in _EMPTY:
return None, True
if norm in _AVAILABLE:
return AvailabilityStatus.AVAILABLE, True
if norm in _IF_NEEDED:
return AvailabilityStatus.IF_NEEDED, True
if norm in _UNAVAILABLE:
return AvailabilityStatus.UNAVAILABLE, True
return None, False