Bénévole : retrait de la taille de t-shirt et du régime alimentaire
Champs `tshirt_size` et `diet` supprimés du modèle `Volunteer`, des schémas d'API, de l'import CSV/Excel (mapping, suggestions, exécution) et du jeu de démonstration. Énum `TshirtSize` et helper `parse_tshirt_size` supprimés. Migration 0005. Clé i18n `import.row.invalid_tshirt_size` retirée. 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
ccf3438eef
commit
609514369a
@@ -65,8 +65,8 @@ les tables sauf les tables d'association.
|
|||||||
l'écriture).
|
l'écriture).
|
||||||
- **`Volunteer`** : `email` (unique **par édition**, nullable pour les
|
- **`Volunteer`** : `email` (unique **par édition**, nullable pour les
|
||||||
bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`,
|
bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`,
|
||||||
`last_name`, `phone`, `tshirt_size`, `diet`, `comment`. Pas de date de
|
`last_name`, `phone`, `comment`. Pas de date de naissance, ni de taille de
|
||||||
naissance.
|
t-shirt / régime alimentaire.
|
||||||
- **`Skill`** : référentiel par édition (`code`, `label`). N-N `Volunteer`
|
- **`Skill`** : référentiel par édition (`code`, `label`). N-N `Volunteer`
|
||||||
(`volunteer_skill`). N-N `Shift` (`shift_required_skill`).
|
(`volunteer_skill`). N-N `Shift` (`shift_required_skill`).
|
||||||
- **`Position`** : `name`, `location`, `category` (`PositionCategory`),
|
- **`Position`** : `name`, `location`, `category` (`PositionCategory`),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from app.models import (
|
|||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
TshirtSize,
|
|
||||||
)
|
)
|
||||||
from app.validation import Violation
|
from app.validation import Violation
|
||||||
|
|
||||||
@@ -49,8 +48,6 @@ class VolunteerCreate(ApiModel):
|
|||||||
last_name: str
|
last_name: str
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
tshirt_size: TshirtSize | None = None
|
|
||||||
diet: str | None = None
|
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
skill_codes: list[str] = Field(default_factory=list)
|
skill_codes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -60,8 +57,6 @@ class VolunteerUpdate(ApiModel):
|
|||||||
last_name: str | None = None
|
last_name: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
tshirt_size: TshirtSize | None = None
|
|
||||||
diet: str | None = None
|
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
# None = inchangé ; [] = vider les compétences.
|
# None = inchangé ; [] = vider les compétences.
|
||||||
skill_codes: list[str] | None = None
|
skill_codes: list[str] | None = None
|
||||||
@@ -74,8 +69,6 @@ class VolunteerRead(ApiModel):
|
|||||||
last_name: str
|
last_name: str
|
||||||
email: str | None
|
email: str | None
|
||||||
phone: str | None
|
phone: str | None
|
||||||
tshirt_size: TshirtSize | None
|
|
||||||
diet: str | None
|
|
||||||
comment: str | None
|
comment: str | None
|
||||||
skill_codes: list[str]
|
skill_codes: list[str]
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
"import.row.duplicate_name_in_file": "Nom et prénom déjà présents à la ligne {first_line} du fichier.",
|
"import.row.duplicate_name_in_file": "Nom et prénom déjà présents à la ligne {first_line} du fichier.",
|
||||||
"import.row.updated_existing": "Bénévole existant mis à jour.",
|
"import.row.updated_existing": "Bénévole existant mis à jour.",
|
||||||
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
|
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
|
||||||
"import.row.invalid_tshirt_size": "Taille de t-shirt non reconnue : {value}.",
|
|
||||||
"import.row.skill_created": "Nouvelle compétence créée : {label}.",
|
"import.row.skill_created": "Nouvelle compétence créée : {label}.",
|
||||||
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »).",
|
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »).",
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ class ColumnMapping(BaseModel):
|
|||||||
last_name: str
|
last_name: str
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
tshirt_size: str | None = None
|
|
||||||
diet: str | None = None
|
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
skills: str | None = None
|
skills: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from app.importing.schemas import (
|
|||||||
RowReport,
|
RowReport,
|
||||||
)
|
)
|
||||||
from app.importing.text import normalize, slug_code, split_skills
|
from app.importing.text import normalize, slug_code, split_skills
|
||||||
from app.importing.values import parse_availability_cell, parse_tshirt_size
|
from app.importing.values import parse_availability_cell
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Availability,
|
Availability,
|
||||||
DayPart,
|
DayPart,
|
||||||
@@ -29,8 +29,6 @@ _FIELD_HINTS: dict[str, tuple[str, ...]] = {
|
|||||||
"first_name": ("prenom", "first name", "firstname", "given name"),
|
"first_name": ("prenom", "first name", "firstname", "given name"),
|
||||||
"last_name": ("nom", "last name", "lastname", "surname", "famille"),
|
"last_name": ("nom", "last name", "lastname", "surname", "famille"),
|
||||||
"phone": ("tel", "phone", "portable", "mobile", "gsm"),
|
"phone": ("tel", "phone", "portable", "mobile", "gsm"),
|
||||||
"tshirt_size": ("tshirt", "t shirt", "taille", "shirt"),
|
|
||||||
"diet": ("regime", "alimentaire", "diet", "allergie"),
|
|
||||||
"comment": ("commentaire", "remarque", "note", "precision"),
|
"comment": ("commentaire", "remarque", "note", "precision"),
|
||||||
"skills": ("competence", "skill", "habilitation", "brevet", "diplome"),
|
"skills": ("competence", "skill", "habilitation", "brevet", "diplome"),
|
||||||
}
|
}
|
||||||
@@ -82,7 +80,7 @@ def validate_mapping(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
for field in ("email", "phone", "tshirt_size", "diet", "comment", "skills"):
|
for field in ("email", "phone", "comment", "skills"):
|
||||||
header = getattr(columns, field)
|
header = getattr(columns, field)
|
||||||
if header and header not in headers:
|
if header and header not in headers:
|
||||||
errors.append(
|
errors.append(
|
||||||
@@ -281,25 +279,8 @@ def run_import(
|
|||||||
volunteer.email = email_raw or None
|
volunteer.email = email_raw or None
|
||||||
if columns.phone:
|
if columns.phone:
|
||||||
volunteer.phone = (row.get(columns.phone) or "").strip() or None
|
volunteer.phone = (row.get(columns.phone) or "").strip() or None
|
||||||
if columns.diet:
|
|
||||||
volunteer.diet = (row.get(columns.diet) or "").strip() or None
|
|
||||||
if columns.comment:
|
if columns.comment:
|
||||||
volunteer.comment = (row.get(columns.comment) or "").strip() or None
|
volunteer.comment = (row.get(columns.comment) or "").strip() or None
|
||||||
if columns.tshirt_size:
|
|
||||||
raw_size = (row.get(columns.tshirt_size) or "").strip()
|
|
||||||
if raw_size:
|
|
||||||
size = parse_tshirt_size(raw_size)
|
|
||||||
if size is None:
|
|
||||||
entry.issues.append(
|
|
||||||
Issue(
|
|
||||||
code="import.row.invalid_tshirt_size",
|
|
||||||
severity="WARNING",
|
|
||||||
field="tshirt_size",
|
|
||||||
params={"value": raw_size},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
volunteer.tshirt_size = size
|
|
||||||
|
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Interprétation des valeurs de cellule : disponibilités et tailles de t-shirt."""
|
"""Interprétation des valeurs de cellule : disponibilités."""
|
||||||
|
|
||||||
from app.importing.text import normalize
|
from app.importing.text import normalize
|
||||||
from app.models import AvailabilityStatus, TshirtSize
|
from app.models import AvailabilityStatus
|
||||||
|
|
||||||
_AVAILABLE = {
|
_AVAILABLE = {
|
||||||
"x", "o", "oui", "y", "yes", "ok", "1", "vrai", "true",
|
"x", "o", "oui", "y", "yes", "ok", "1", "vrai", "true",
|
||||||
@@ -19,18 +19,6 @@ _UNAVAILABLE = {
|
|||||||
}
|
}
|
||||||
_EMPTY = {"", "na", "n/a", "nc"}
|
_EMPTY = {"", "na", "n/a", "nc"}
|
||||||
|
|
||||||
_TSHIRT = {
|
|
||||||
"XS": TshirtSize.XS,
|
|
||||||
"S": TshirtSize.S,
|
|
||||||
"M": TshirtSize.M,
|
|
||||||
"L": TshirtSize.L,
|
|
||||||
"XL": TshirtSize.XL,
|
|
||||||
"XXL": TshirtSize.XXL,
|
|
||||||
"2XL": TshirtSize.XXL,
|
|
||||||
"XXXL": TshirtSize.XXXL,
|
|
||||||
"3XL": TshirtSize.XXXL,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_availability_cell(value: str) -> tuple[AvailabilityStatus | None, bool]:
|
def parse_availability_cell(value: str) -> tuple[AvailabilityStatus | None, bool]:
|
||||||
"""(statut, reconnu).
|
"""(statut, reconnu).
|
||||||
@@ -48,8 +36,3 @@ def parse_availability_cell(value: str) -> tuple[AvailabilityStatus | None, bool
|
|||||||
if norm in _UNAVAILABLE:
|
if norm in _UNAVAILABLE:
|
||||||
return AvailabilityStatus.UNAVAILABLE, True
|
return AvailabilityStatus.UNAVAILABLE, True
|
||||||
return None, False
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
def parse_tshirt_size(value: str) -> TshirtSize | None:
|
|
||||||
key = normalize(value).upper().replace(" ", "").replace("-", "")
|
|
||||||
return _TSHIRT.get(key)
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Bénévole : retrait de tshirt_size et diet
|
||||||
|
|
||||||
|
Revision ID: 0005_drop_volunteer_tshirt_diet
|
||||||
|
Revises: 0004_shift_day_part
|
||||||
|
Create Date: 2026-09-10
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0005_drop_volunteer_tshirt_diet"
|
||||||
|
down_revision: str | None = "0004_shift_day_part"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_TSHIRT_SIZE = sa.Enum(
|
||||||
|
"XS", "S", "M", "L", "XL", "XXL", "XXXL",
|
||||||
|
name="tshirtsize", native_enum=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("volunteer", "tshirt_size")
|
||||||
|
op.drop_column("volunteer", "diet")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"volunteer", sa.Column("diet", sa.String(), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"volunteer",
|
||||||
|
sa.Column("tshirt_size", _TSHIRT_SIZE, nullable=True),
|
||||||
|
)
|
||||||
@@ -24,7 +24,6 @@ from app.models.enums import (
|
|||||||
AvailabilityStatus,
|
AvailabilityStatus,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
TshirtSize,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -43,7 +42,6 @@ __all__ = [
|
|||||||
"ShiftRequiredSkill",
|
"ShiftRequiredSkill",
|
||||||
"Skill",
|
"Skill",
|
||||||
"TimestampMixin",
|
"TimestampMixin",
|
||||||
"TshirtSize",
|
|
||||||
"Volunteer",
|
"Volunteer",
|
||||||
"VolunteerSkill",
|
"VolunteerSkill",
|
||||||
"local_now",
|
"local_now",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from app.models.enums import (
|
|||||||
AvailabilityStatus,
|
AvailabilityStatus,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
TshirtSize,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_PASSIVE = {"passive_deletes": True}
|
_PASSIVE = {"passive_deletes": True}
|
||||||
@@ -138,11 +137,6 @@ class Volunteer(TimestampMixin, table=True):
|
|||||||
first_name: str
|
first_name: str
|
||||||
last_name: str
|
last_name: str
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
tshirt_size: TshirtSize | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(SAEnum(TshirtSize, native_enum=False), nullable=True),
|
|
||||||
)
|
|
||||||
diet: str | None = None
|
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
|
||||||
skills: list[Skill] = Relationship(
|
skills: list[Skill] = Relationship(
|
||||||
|
|||||||
@@ -34,13 +34,3 @@ class PositionCategory(enum.StrEnum):
|
|||||||
SETUP = "SETUP"
|
SETUP = "SETUP"
|
||||||
STAGE = "STAGE"
|
STAGE = "STAGE"
|
||||||
OTHER = "OTHER"
|
OTHER = "OTHER"
|
||||||
|
|
||||||
|
|
||||||
class TshirtSize(enum.StrEnum):
|
|
||||||
XS = "XS"
|
|
||||||
S = "S"
|
|
||||||
M = "M"
|
|
||||||
L = "L"
|
|
||||||
XL = "XL"
|
|
||||||
XXL = "XXL"
|
|
||||||
XXXL = "XXXL"
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ from app.models import (
|
|||||||
Shift,
|
Shift,
|
||||||
ShiftRequiredSkill,
|
ShiftRequiredSkill,
|
||||||
Skill,
|
Skill,
|
||||||
TshirtSize,
|
|
||||||
Volunteer,
|
Volunteer,
|
||||||
VolunteerSkill,
|
VolunteerSkill,
|
||||||
)
|
)
|
||||||
@@ -354,12 +353,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
last_name=last,
|
last_name=last,
|
||||||
email=email,
|
email=email,
|
||||||
phone=(f"06{rng.randint(10_000_000, 99_999_999)}" if rng.random() < 0.8 else None),
|
phone=(f"06{rng.randint(10_000_000, 99_999_999)}" if rng.random() < 0.8 else None),
|
||||||
tshirt_size=(rng.choice(list(TshirtSize)) if rng.random() < 0.9 else None),
|
|
||||||
diet=(
|
|
||||||
rng.choice(["Végétarien", "Végétalien", "Sans gluten", "Sans porc"])
|
|
||||||
if rng.random() < 0.25
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
session.add(volunteer)
|
session.add(volunteer)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|||||||
@@ -23,14 +23,12 @@ def test_create_volunteer_with_skills(client, edition):
|
|||||||
"first_name": "Alex",
|
"first_name": "Alex",
|
||||||
"last_name": "Martin",
|
"last_name": "Martin",
|
||||||
"email": "alex@ex.fr",
|
"email": "alex@ex.fr",
|
||||||
"tshirt_size": "M",
|
|
||||||
"skill_codes": ["SST", "PERMIS_B", "SST"],
|
"skill_codes": ["SST", "PERMIS_B", "SST"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["skill_codes"] == ["PERMIS_B", "SST"]
|
assert body["skill_codes"] == ["PERMIS_B", "SST"]
|
||||||
assert body["tshirt_size"] == "M"
|
|
||||||
assert body["edition_id"] == str(edition.id)
|
assert body["edition_id"] == str(edition.id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ def _mapping(dry_run: bool = False) -> ImportMapping:
|
|||||||
first_name="Prénom",
|
first_name="Prénom",
|
||||||
last_name="Nom",
|
last_name="Nom",
|
||||||
email="Email",
|
email="Email",
|
||||||
tshirt_size="Taille",
|
|
||||||
skills="Compétences",
|
skills="Compétences",
|
||||||
),
|
),
|
||||||
availability_columns=[
|
availability_columns=[
|
||||||
@@ -78,7 +77,6 @@ def test_happy_path_creates_volunteers_skills_and_availabilities(session, editio
|
|||||||
|
|
||||||
volunteers = {v.email: v for v in _volunteers(session, edition)}
|
volunteers = {v.email: v for v in _volunteers(session, edition)}
|
||||||
assert set(volunteers) == {"alex@ex.fr", "sam@ex.fr"}
|
assert set(volunteers) == {"alex@ex.fr", "sam@ex.fr"}
|
||||||
assert volunteers["alex@ex.fr"].tshirt_size.value == "M"
|
|
||||||
assert {s.code for s in volunteers["alex@ex.fr"].skills} == {"SST", "PERMIS_B"}
|
assert {s.code for s in volunteers["alex@ex.fr"].skills} == {"SST", "PERMIS_B"}
|
||||||
|
|
||||||
avails = session.exec(
|
avails = session.exec(
|
||||||
@@ -194,18 +192,6 @@ def test_invalid_email_is_skipped(session, edition):
|
|||||||
assert report.rows[0].issues[0].code == "import.row.invalid_email"
|
assert report.rows[0].issues[0].code == "import.row.invalid_email"
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_tshirt_size_warns_and_still_imports(session, edition):
|
|
||||||
report = run_import(
|
|
||||||
session=session,
|
|
||||||
edition=edition,
|
|
||||||
table=_table([["Alex", "Martin", "a@ex.fr", "Large", "", "x", ""]]),
|
|
||||||
mapping=_mapping(),
|
|
||||||
)
|
|
||||||
assert report.created == 1
|
|
||||||
assert report.rows[0].issues[0].code == "import.row.invalid_tshirt_size"
|
|
||||||
assert _volunteers(session, edition)[0].tshirt_size is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_availability_value_is_flagged(session, edition):
|
def test_unknown_availability_value_is_flagged(session, edition):
|
||||||
report = run_import(
|
report = run_import(
|
||||||
session=session,
|
session=session,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.importing.text import slug_code, split_skills
|
from app.importing.text import slug_code, split_skills
|
||||||
from app.importing.values import parse_availability_cell, parse_tshirt_size
|
from app.importing.values import parse_availability_cell
|
||||||
from app.models import AvailabilityStatus, TshirtSize
|
from app.models import AvailabilityStatus
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -32,24 +32,6 @@ def test_unknown_cell_is_flagged():
|
|||||||
assert parse_availability_cell("bof") == (None, False)
|
assert parse_availability_cell("bof") == (None, False)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("raw", "expected"),
|
|
||||||
[
|
|
||||||
("M", TshirtSize.M),
|
|
||||||
(" m ", TshirtSize.M),
|
|
||||||
("xl", TshirtSize.XL),
|
|
||||||
("2XL", TshirtSize.XXL),
|
|
||||||
("3 XL", TshirtSize.XXXL),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_tshirt_sizes(raw, expected):
|
|
||||||
assert parse_tshirt_size(raw) is expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_tshirt_size_is_none():
|
|
||||||
assert parse_tshirt_size("large") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_slug_code_is_stable_across_accents_and_case():
|
def test_slug_code_is_stable_across_accents_and_case():
|
||||||
assert slug_code("Régie son") == "REGIE_SON"
|
assert slug_code("Régie son") == "REGIE_SON"
|
||||||
assert slug_code(" permis B ") == "PERMIS_B"
|
assert slug_code(" permis B ") == "PERMIS_B"
|
||||||
|
|||||||
Reference in New Issue
Block a user