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:
co-authored by
Claude Sonnet 5
parent
81609eecc5
commit
be51770b51
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user