Bénévole : l'email n'est plus unique (couple, famille)

Plusieurs bénévoles peuvent légitimement partager une même adresse email —
l'architecture l'interdisait : l'import ignorait silencieusement le second
(« duplicate_email_in_file »), et l'ajout manuel renvoyait un 409. Les deux
sont corrigés.

- Migration 0009 : retrait de la contrainte d'unicité (edition_id, email) sur
  `volunteer` (l'index sur `email` seul est conservé).
- Import (`app/importing/service.py`) : l'upsert se fait désormais sur
  (email, nom + prénom), pas sur l'email seul. Un même email revu avec un nom
  différent dans le fichier crée un second bénévole (nouveau code
  `import.row.shared_email`, WARNING) au lieu d'être ignoré ;
  `duplicate_email_in_file` ne se déclenche plus que sur une ligne
  littéralement répétée (même email ET même nom).
- API (`POST`/`PATCH /volunteers`) : retrait du rejet 409
  `volunteer.duplicate_email`, devenu sans objet (le mock ne l'a d'ailleurs
  jamais appliqué à la création manuelle).
- Mock frontend (`mock-client.ts`) : même correction de l'upsert par import,
  pour rester cohérent avec le backend.
- CLAUDE.md à jour (modèle de domaine, règles d'import, journal).

Contrepartie assumée (documentée) : un email re-importé avec un nom
légèrement différent (faute de frappe corrigée) crée désormais un bénévole
de plus signalé, plutôt que de renommer silencieusement l'existant — même
philosophie que la détection sans email, qui ne fusionne jamais rien
automatiquement.

Vérifié : 179 tests backend, ruff, alembic check ; typecheck/lint/build/smoke
frontend ; reseed de démonstration.

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-15 19:38:28 +02:00
co-authored by Claude Sonnet 5
parent fcc42c8d43
commit 897a76ca6d
11 changed files with 193 additions and 70 deletions
+7 -5
View File
@@ -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):
+57 -8
View File
@@ -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
):
+4 -5
View File
@@ -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):