diff --git a/CLAUDE.md b/CLAUDE.md index 62a9059..2b9fbf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,12 +65,13 @@ les tables sauf les tables d'association. postes (pas les disponibilités, qui sont des fenêtres libres). CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour l'écriture). -- **`Volunteer`** : `email` (unique **par édition**, nullable pour les - bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`, - `last_name`, `phone`, `comment`. Pas de date de naissance, taille de - t-shirt, régime, ni compétences. Porte deux listes de vœux, exposées par la - fiche (`artist_ids`, `wished_position_ids` ; `None`/absent = inchangé, - `[]` = vider). +- **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles + peuvent partager une même adresse, ex. un couple ou une famille ; c'est le + couple (email, nom + prénom) qui identifie un bénévole, pas l'email seul), + `first_name`, `last_name`, `phone`, `comment`. Pas de date de naissance, + taille de t-shirt, régime, ni compétences. Porte deux listes de vœux, + exposées par la fiche (`artist_ids`, `wished_position_ids` ; `None`/absent = + inchangé, `[]` = vider). - **`Artist`** : programmation de l'édition (`name`, `performance_date`, `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le lendemain). Unique (`edition_id`, `name`, `performance_date`). La @@ -308,7 +309,10 @@ défilement horizontal — jamais de colonnes étroites par défaut. commentaire — pas les disponibilités, qui se déclarent dans l'app), en deux temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping validé. Rapport ligne par ligne. Détection des doublons : upsert sur - `email` ; sans email, détection sur (`last_name`, `first_name`) signalée + (`email`, `last_name` + `first_name`) — l'email seul ne suffit pas, + plusieurs bénévoles peuvent le partager (couple, famille) ; un même email + revu avec un nom différent crée un bénévole de plus (signalé, jamais + ignoré) ; sans email, détection sur (`last_name`, `first_name`) signalée sans upsert automatique. Mode simulation qui ne persiste rien. 3. API CRUD sur `Position`, `Shift`, `Volunteer`, `Assignment`. 4. Moteur de validation (section 3). @@ -460,6 +464,7 @@ Aucune dépendance locale requise hors Docker. | 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 | +| 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index de766c9..527e110 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -1,9 +1,11 @@ -"""CRUD des bénévoles. La suppression est un effacement complet (RGPD).""" +"""CRUD des bénévoles. La suppression est un effacement complet (RGPD). + +L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même +(couple, famille) — voir `app/models/domain.py:Volunteer`.""" import uuid from fastapi import APIRouter, Depends, HTTPException, Response, status -from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select from app.api._common import owned_or_404, replace_links @@ -143,13 +145,7 @@ def create_volunteer( **payload.model_dump(exclude={"artist_ids", "wished_position_ids"}), ) session.add(volunteer) - try: - session.flush() - except IntegrityError as exc: - session.rollback() - raise HTTPException( - status_code=409, detail={"code": "volunteer.duplicate_email"} - ) from exc + session.flush() _set_artists(session, volunteer, payload.artist_ids) _set_wished_positions(session, volunteer, payload.wished_position_ids) session.commit() @@ -197,13 +193,7 @@ def update_volunteer( wished_position_ids = changes.pop("wished_position_ids", None) for field, value in changes.items(): setattr(volunteer, field, value) - try: - session.flush() - except IntegrityError as exc: - session.rollback() - raise HTTPException( - status_code=409, detail={"code": "volunteer.duplicate_email"} - ) from exc + session.flush() if artist_ids is not None: _set_artists(session, volunteer, artist_ids) if wished_position_ids is not None: diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 83ec0d2..fece772 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -10,7 +10,6 @@ "day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.", "shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.", - "volunteer.duplicate_email": "Un bénévole avec cet email existe déjà dans l'édition.", "artist.name_required": "Le nom de l'artiste ne peut pas être vide.", "artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.", "artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.", @@ -33,7 +32,8 @@ "import.mapping.column_not_found": "La colonne « {column} » est absente du fichier.", "import.row.missing_required_field": "Champ obligatoire manquant : {field}.", "import.row.invalid_email": "Adresse email invalide : {value}.", - "import.row.duplicate_email_in_file": "Email déjà présent à la ligne {first_line} du fichier.", + "import.row.duplicate_email_in_file": "Même bénévole (nom et email) déjà présent à la ligne {first_line} du fichier.", + "import.row.shared_email": "Email déjà utilisé par un autre bénévole à 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.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.", diff --git a/backend/app/importing/service.py b/backend/app/importing/service.py index 9c30605..f7e5df3 100644 --- a/backend/app/importing/service.py +++ b/backend/app/importing/service.py @@ -2,6 +2,12 @@ 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. + +Détection des doublons : upsert sur (email, nom + prénom) — l'email seul ne +suffit pas, plusieurs bénévoles peuvent le partager (couple, famille) ; sans +email, détection sur (nom, prénom) signalée sans upsert automatique. Un même +email revu avec un nom différent crée un bénévole de plus (signalé), jamais +ignoré. """ import re @@ -104,14 +110,20 @@ def run_import( select(Volunteer).where(Volunteer.edition_id == edition.id) ) ) - by_email = {v.email.lower(): v for v in existing if v.email} + # Plusieurs bénévoles peuvent partager un même email (couple, famille) : + # l'email seul ne suffit pas à identifier un bénévole existant, il faut + # l'associer au nom. `by_name` ne sert qu'aux bénévoles sans email. + by_email_and_name: dict[tuple[str, tuple[str, str]], Volunteer] = {} by_name: dict[tuple[str, str], Volunteer] = {} for volunteer in existing: - if not volunteer.email: - key = (normalize(volunteer.last_name), normalize(volunteer.first_name)) + key = (normalize(volunteer.last_name), normalize(volunteer.first_name)) + if volunteer.email: + by_email_and_name[(volunteer.email.lower(), key)] = volunteer + else: by_name.setdefault(key, volunteer) report = ImportReport(dry_run=mapping.dry_run, total_rows=len(table.rows)) + row_seen: dict[tuple[str, tuple[str, str]], int] = {} email_seen: dict[str, int] = {} name_seen: dict[tuple[str, str], int] = {} @@ -151,34 +163,50 @@ def run_import( report.skipped += 1 continue + name_key = (normalize(last_name), normalize(first_name)) if email: - if email in email_seen: + row_key = (email, name_key) + if row_key in row_seen: + # Même email ET même nom déjà vus : la ligne est répétée dans + # le fichier (copier-coller), pas deux bénévoles distincts. entry.issues.append( Issue( code="import.row.duplicate_email_in_file", severity="ERROR", field="email", - params={"value": email_raw, "first_line": email_seen[email]}, + params={"value": email_raw, "first_line": row_seen[row_key]}, ) ) report.rows.append(entry) report.skipped += 1 continue - email_seen[email] = line + if email in email_seen: + # Même email, nom différent : deux bénévoles peuvent partager + # une adresse (couple, famille) — on les crée tous les deux, + # simplement signalé pour la coordination. + entry.issues.append( + Issue( + code="import.row.shared_email", + severity="WARNING", + field="email", + params={"value": email_raw, "first_line": email_seen[email]}, + ) + ) + row_seen[row_key] = line + email_seen.setdefault(email, line) else: - key = (normalize(last_name), normalize(first_name)) - if key in name_seen: + if name_key in name_seen: entry.issues.append( Issue( code="import.row.duplicate_name_in_file", severity="WARNING", - params={"first_line": name_seen[key]}, + params={"first_line": name_seen[name_key]}, ) ) else: - name_seen[key] = line + name_seen[name_key] = line - volunteer = by_email.get(email) if email else None + volunteer = by_email_and_name.get((email, name_key)) if email else None if volunteer is not None: entry.action = "UPDATED" entry.issues.append( @@ -190,7 +218,7 @@ def run_import( ) else: if not email: - match = by_name.get((normalize(last_name), normalize(first_name))) + match = by_name.get(name_key) if match is not None: entry.issues.append( Issue( diff --git a/backend/app/migrations/versions/0009_volunteer_email_not_unique.py b/backend/app/migrations/versions/0009_volunteer_email_not_unique.py new file mode 100644 index 0000000..d70147f --- /dev/null +++ b/backend/app/migrations/versions/0009_volunteer_email_not_unique.py @@ -0,0 +1,32 @@ +"""Bénévole : l'email n'est plus unique par édition + +Revision ID: 0009_volunteer_email_not_unique +Revises: 0008_artist_performance_time +Create Date: 2026-09-15 + +Plusieurs bénévoles peuvent partager une même adresse (couple, famille) : +l'import les traitait auparavant comme des doublons et ignorait le second. +L'unicité (edition_id, email) est retirée ; l'index sur `email` seul est +conservé pour les recherches. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0009_volunteer_email_not_unique" +down_revision: str | None = "0008_artist_performance_time" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_constraint( + "uq_volunteer_edition_email", "volunteer", type_="unique" + ) + + +def downgrade() -> None: + op.create_unique_constraint( + "uq_volunteer_edition_email", "volunteer", ["edition_id", "email"] + ) diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 888dc16..55f8b83 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -133,18 +133,14 @@ class Artist(TimestampMixin, table=True): class Volunteer(TimestampMixin, table=True): - __table_args__ = ( - UniqueConstraint( - "edition_id", "email", name="uq_volunteer_edition_email" - ), - ) - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) edition_id: uuid.UUID = Field( foreign_key="edition.id", index=True, ondelete="CASCADE" ) - # Identifiant fonctionnel unique par édition. Nullable : bénévole créé à la - # main par la coordination, sans email. + # Pas unique : plusieurs bénévoles peuvent partager une même adresse (ex. + # un couple, une famille). L'import (app/importing/service.py) distingue + # deux bénévoles d'un même email par leur nom ; la coordination reste + # seule juge en cas d'ambiguïté réelle. email: str | None = Field(default=None, index=True) first_name: str last_name: str diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py index a5c2daa..76273bf 100644 --- a/backend/tests/test_api_volunteers.py +++ b/backend/tests/test_api_volunteers.py @@ -34,12 +34,14 @@ def test_create_volunteer(client, edition): assert body["wished_position_ids"] == [] -def test_create_duplicate_email_conflicts(client, edition): +def test_several_volunteers_can_share_an_email(client, edition): + # Un couple, une famille : même boîte mail, deux bénévoles distincts. payload = {"first_name": "A", "last_name": "A", "email": "dup@ex.fr"} - assert client.post(_url(edition), json=payload).status_code == 201 - resp = client.post(_url(edition), json={**payload, "first_name": "B"}) - assert resp.status_code == 409 - assert resp.json()["detail"]["code"] == "volunteer.duplicate_email" + first = client.post(_url(edition), json=payload) + assert first.status_code == 201 + second = client.post(_url(edition), json={**payload, "first_name": "B"}) + assert second.status_code == 201 + assert first.json()["id"] != second.json()["id"] def test_volunteers_without_email_are_allowed(client, edition): diff --git a/backend/tests/test_import_service.py b/backend/tests/test_import_service.py index b5f2cae..384700d 100644 --- a/backend/tests/test_import_service.py +++ b/backend/tests/test_import_service.py @@ -75,13 +75,13 @@ def test_dry_run_persists_nothing(session, edition): assert _volunteers(session, edition) == [] -def test_existing_email_is_updated_not_duplicated(session, edition): +def test_existing_email_and_name_is_updated_not_duplicated(session, edition): session.add( Volunteer( edition_id=edition.id, email="alex@ex.fr", - first_name="Alexandre", - last_name="M.", + first_name="Alex", + last_name="Martin", phone="0600000000", ) ) @@ -90,7 +90,7 @@ def test_existing_email_is_updated_not_duplicated(session, edition): report = run_import( session=session, edition=edition, - table=_table([["Alex", "Martin", "ALEX@ex.fr", "", ""]]), + table=_table([["alex", "MARTIN", "ALEX@ex.fr", "0611111111", ""]]), mapping=_mapping(), ) @@ -98,18 +98,46 @@ def test_existing_email_is_updated_not_duplicated(session, edition): assert report.rows[0].issues[0].code == "import.row.updated_existing" volunteers = _volunteers(session, edition) assert len(volunteers) == 1 - assert volunteers[0].first_name == "Alex" - assert volunteers[0].last_name == "Martin" + assert volunteers[0].phone == "0611111111" -def test_duplicate_email_within_file_is_skipped(session, edition): +def test_same_email_different_name_creates_a_second_volunteer(session, edition): + # Un même email peut être partagé (couple, famille) : il ne suffit plus à + # lui seul à identifier « le » bénévole, il faut aussi le nom — sinon un + # second bénévole avec la même adresse était perdu (silencieusement fondu + # dans le premier, ou ignoré selon le cas). + session.add( + Volunteer( + edition_id=edition.id, + email="famille@ex.fr", + first_name="Alex", + last_name="Martin", + ) + ) + session.commit() + + report = run_import( + session=session, + edition=edition, + table=_table([["Sam", "Martin", "famille@ex.fr", "", ""]]), + mapping=_mapping(), + ) + + assert (report.created, report.updated, report.skipped) == (1, 0, 0) + assert report.rows[0].action == "CREATED" + names = {(v.first_name, v.last_name) for v in _volunteers(session, edition)} + assert names == {("Alex", "Martin"), ("Sam", "Martin")} + + +def test_duplicate_row_within_file_is_skipped(session, edition): + # Même nom ET même email répétés : une ligne recopiée, pas deux bénévoles. report = run_import( session=session, edition=edition, table=_table( [ ["Alex", "Martin", "dup@ex.fr", "", ""], - ["Alex", "Doublon", "dup@ex.fr", "", ""], + ["alex", "MARTIN", "DUP@ex.fr", "", ""], ] ), mapping=_mapping(), @@ -121,6 +149,27 @@ def test_duplicate_email_within_file_is_skipped(session, edition): assert skipped.issues[0].params["first_line"] == 2 +def test_shared_email_within_file_creates_both_with_a_warning(session, edition): + report = run_import( + session=session, + edition=edition, + table=_table( + [ + ["Alex", "Martin", "famille@ex.fr", "", ""], + ["Sam", "Martin", "famille@ex.fr", "", ""], + ] + ), + mapping=_mapping(), + ) + assert (report.created, report.skipped) == (2, 0) + second = report.rows[1] + assert second.action == "CREATED" + assert second.issues[0].code == "import.row.shared_email" + assert second.issues[0].params["first_line"] == 2 + names = {(v.first_name, v.last_name) for v in _volunteers(session, edition)} + assert names == {("Alex", "Martin"), ("Sam", "Martin")} + + def test_row_without_email_matching_existing_name_warns_but_creates( session, edition ): diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index cfd74c0..55be778 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -1,8 +1,6 @@ import datetime as dt -import pytest from sqlalchemy import text -from sqlalchemy.exc import IntegrityError from app.models import ( Assignment, @@ -121,7 +119,9 @@ def test_availability_window_round_trip(session): assert reloaded.end_time == dt.time(2, 0) -def test_volunteer_email_unique_per_edition(session): +def test_volunteer_email_can_be_shared(session): + # Un couple, une famille : même boîte mail, deux bénévoles distincts — + # ce n'est plus une contrainte d'unicité en base (voir migration 0009). edition = _edition(session) session.add( Volunteer( @@ -140,8 +140,7 @@ def test_volunteer_email_unique_per_edition(session): last_name="B", ) ) - with pytest.raises(IntegrityError): - session.commit() + session.commit() # ne lève pas def test_multiple_volunteers_without_email_allowed(session): diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 3cfbab1..67b5ff7 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -810,6 +810,7 @@ export function createMockApi(): PlanningApi { skipped: 0, rows: [], }; + const rowSeen = new Map(); // clé `${email}|${nom normalisé}` const emailSeen = new Map(); // On travaille sur des copies : `state` n'est touché qu'en fin si !dry_run. const working = state.volunteers.map((v) => ({ ...v })); @@ -849,24 +850,45 @@ export function createMockApi(): PlanningApi { report.skipped += 1; return; } + const nameKey = `${normalizeText(last)}|${normalizeText(first)}`; if (email) { - const seenAt = emailSeen.get(email); - if (seenAt != null) { + const rowKey = `${email}|${nameKey}`; + const rowSeenAt = rowSeen.get(rowKey); + if (rowSeenAt != null) { + // Même email ET même nom déjà vus : ligne répétée dans le + // fichier (copier-coller), pas deux bénévoles distincts. entry.issues.push({ code: 'import.row.duplicate_email_in_file', severity: 'ERROR', field: 'email', - params: { value: emailRaw, first_line: seenAt }, + params: { value: emailRaw, first_line: rowSeenAt }, }); report.rows.push(entry); report.skipped += 1; return; } - emailSeen.set(email, line); + const emailSeenAt = emailSeen.get(email); + if (emailSeenAt != null) { + // Même email, nom différent : deux bénévoles peuvent partager + // une adresse (couple, famille) — on les crée tous les deux. + entry.issues.push({ + code: 'import.row.shared_email', + severity: 'WARNING', + field: 'email', + params: { value: emailRaw, first_line: emailSeenAt }, + }); + } + rowSeen.set(rowKey, line); + if (emailSeenAt == null) emailSeen.set(email, line); } let vol = email - ? working.find((v) => (v.email ?? '').toLowerCase() === email) + ? working.find( + (v) => + (v.email ?? '').toLowerCase() === email && + normalizeText(v.last_name) === normalizeText(last) && + normalizeText(v.first_name) === normalizeText(first), + ) : undefined; if (vol) { entry.action = 'UPDATED'; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 76436ee..0dc6ce0 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -13,7 +13,6 @@ "error.validation_failed": "La validation a échoué (règle bloquante)", "assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau", "assignment.wrong_edition": "Bénévole ou créneau hors de cette édition", - "volunteer.duplicate_email": "Un bénévole avec cet email existe déjà", "shift.ends_not_after_starts": "La fin doit être après le début", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition", "auth.invalid_credentials": "Email ou mot de passe incorrect", @@ -264,7 +263,8 @@ "import.mapping.column_not_found": "La colonne « {column} » est absente du fichier.", "import.row.missing_required_field": "Champ obligatoire manquant : {field}.", "import.row.invalid_email": "Adresse email invalide : {value}.", - "import.row.duplicate_email_in_file": "Email déjà présent à la ligne {first_line} du fichier.", + "import.row.duplicate_email_in_file": "Même bénévole (nom et email) déjà présent à la ligne {first_line} du fichier.", + "import.row.shared_email": "Email déjà utilisé par un autre bénévole à 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.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l’édition.",