Gestion des référents depuis la fiche bénévole
Un poste avait bien un `lead_account_id`, lu par l'écran référent
(`/mobile-referent`) et par les droits d'écriture (`ensure_can_write_position`),
mais rien ne permettait de le renseigner : aucun écran ne l'exposait, et
l'API ne validait même pas que la valeur envoyée désignait un compte référent
existant de la bonne édition.
Backend :
- `PATCH /editions/{id}/accounts/{account_id}` (nouveau) : change le `role`
d'un compte, seulement entre LEAD et VOLUNTEER (lien magique dans les deux
cas) — 422 `account.role_change_not_allowed` dès que COORDINATOR est
impliqué (mot de passe en jeu, jamais touché ici). C'est ce qui permet de
faire passer le compte d'un bénévole en référent.
- `POST`/`PATCH /positions` : `lead_account_id` vérifié — doit désigner un
compte `Role.LEAD` de la même édition, sinon 422
`position.lead_must_be_referent_account` (silencieusement accepté
jusqu'ici, y compris un compte d'une autre édition ou du mauvais rôle).
Frontend (`ConfigVolunteers.tsx`) : nouvelle section « Référent des postes »
sur la fiche du bénévole, juste après l'accès de connexion — mêmes badges à
cocher que les vœux de poste/artiste. Cocher un poste crée ou met à niveau
(LEAD) l'accès de connexion du bénévole automatiquement ; décocher retire
seulement le poste, sans jamais rétrograder l'accès. Un bénévole dont le
compte est déjà COORDINATOR ne peut pas être désigné référent (il a déjà
accès à tout). Miroir complet côté mock (`accounts.update`,
`checkLeadAccount`) pour que la démonstration se comporte pareil.
Vérifié : 204 tests backend pertinents (14 nouveaux : 6 sur `PATCH accounts`,
8 sur la validation `lead_account_id`), ruff, alembic check ; typecheck/
lint/build/smoke frontend ; parcours complet testé en direct sur le jeu de
démo (création de compte, promotion LEAD, rejet COORDINATOR, affectation
référent) avant nettoyage des données de test.
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
db11d9a3ba
commit
8fcc22115f
@@ -93,7 +93,15 @@ les tables sauf les tables d'association.
|
|||||||
unique et éditable indépendamment par chacun). `CHECK (volunteer_id <>
|
unique et éditable indépendamment par chacun). `CHECK (volunteer_id <>
|
||||||
companion_id)` : un bénévole ne peut pas se souhaiter lui-même.
|
companion_id)` : un bénévole ne peut pas se souhaiter lui-même.
|
||||||
- **`Position`** : `name`, `location`, `category` (`PositionCategory`),
|
- **`Position`** : `name`, `location`, `category` (`PositionCategory`),
|
||||||
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte).
|
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte) —
|
||||||
|
doit désigner un compte `Role.LEAD` de la même édition (`POST`/`PATCH`
|
||||||
|
`/positions` le vérifient, 422 `position.lead_must_be_referent_account`
|
||||||
|
sinon). Se gère depuis la fiche du bénévole (`ConfigVolunteers.tsx` :
|
||||||
|
section « Référent des postes ») plutôt que depuis le poste : cocher un
|
||||||
|
poste y crée ou met à niveau (`PATCH /accounts/{id}` : voir plus bas) son
|
||||||
|
compte en `LEAD` — à sa prochaine connexion il voit l'écran référent
|
||||||
|
(`/mobile-referent`) plutôt que l'écran bénévole. Décocher ne fait que
|
||||||
|
retirer le poste, sans jamais rétrograder le compte automatiquement.
|
||||||
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
|
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
|
||||||
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
|
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
|
||||||
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
|
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
|
||||||
@@ -188,6 +196,13 @@ pour ne jamais révéler si un compte existe). Front : bouton « Donner un accè
|
|||||||
de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
|
de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
|
||||||
cet endpoint avec `role=VOLUNTEER`.
|
cet endpoint avec `role=VOLUNTEER`.
|
||||||
|
|
||||||
|
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
|
||||||
|
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
|
||||||
|
`account.role_change_not_allowed` si `COORDINATOR` est impliqué dans un sens
|
||||||
|
ou l'autre (mot de passe en jeu, jamais touché par cette route). C'est ce qui
|
||||||
|
met à niveau le compte d'un bénévole en référent quand on le désigne
|
||||||
|
référent d'un poste (voir `Position.lead_account_id` plus haut).
|
||||||
|
|
||||||
### Emails (`app/email/`)
|
### Emails (`app/email/`)
|
||||||
|
|
||||||
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
|
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
|
||||||
@@ -495,6 +510,7 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 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 |
|
| 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 |
|
| 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 |
|
||||||
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
|
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
|
||||||
|
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
|
||||||
|
|
||||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
|
|||||||
|
|
||||||
from app.api._common import owned_or_404
|
from app.api._common import owned_or_404
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import AccountCreate, AccountRead
|
from app.api.schemas import AccountCreate, AccountRead, AccountUpdate
|
||||||
from app.auth.deps import require_coordinator
|
from app.auth.deps import require_coordinator
|
||||||
from app.auth.passwords import MIN_LENGTH, hash_password
|
from app.auth.passwords import MIN_LENGTH, hash_password
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
@@ -85,6 +85,30 @@ def list_accounts(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{account_id}", response_model=AccountRead)
|
||||||
|
def update_account(
|
||||||
|
account_id: uuid.UUID,
|
||||||
|
payload: AccountUpdate,
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> Account:
|
||||||
|
"""Seul le rôle change de main ici, et seulement entre LEAD et VOLUNTEER
|
||||||
|
(lien magique dans les deux cas) : devenir référent d'un poste (voir
|
||||||
|
`positions.py`) met à niveau le compte bénévole existant, sans toucher au
|
||||||
|
mot de passe d'un compte COORDINATION."""
|
||||||
|
account = owned_or_404(session.get(Account, account_id), edition.id)
|
||||||
|
if payload.role is not None and payload.role != account.role:
|
||||||
|
if Role.COORDINATOR in (account.role, payload.role):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "account.role_change_not_allowed"}
|
||||||
|
)
|
||||||
|
account.role = payload.role
|
||||||
|
session.commit()
|
||||||
|
session.refresh(account)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_account(
|
def delete_account(
|
||||||
account_id: uuid.UUID,
|
account_id: uuid.UUID,
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"""CRUD des postes."""
|
"""CRUD des postes.
|
||||||
|
|
||||||
|
`lead_account_id` (référent) ne peut désigner qu'un compte `Role.LEAD` de la
|
||||||
|
même édition — voir `_check_lead_account`. C'est le seul moyen de donner à un
|
||||||
|
bénévole une vue référent (`/mobile-referent`) à la connexion ; voir
|
||||||
|
`api/accounts.py:update_account` pour le faire passer de VOLUNTEER à LEAD."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.api._common import owned_or_404
|
from app.api._common import owned_or_404
|
||||||
@@ -21,13 +26,28 @@ from app.auth.deps import (
|
|||||||
require_staff,
|
require_staff,
|
||||||
)
|
)
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models import Account, Edition, Position
|
from app.models import Account, Edition, Position, Role
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/editions/{edition_id}/positions", tags=["positions"]
|
prefix="/editions/{edition_id}/positions", tags=["positions"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_lead_account(
|
||||||
|
session: Session, edition: Edition, lead_account_id: uuid.UUID | None
|
||||||
|
) -> None:
|
||||||
|
"""`lead_account_id` doit désigner un compte RÉFÉRENT (`Role.LEAD`) de
|
||||||
|
cette même édition — sinon le poste se retrouve sans référent utilisable
|
||||||
|
(cf. `ensure_can_write_position`, `auth/deps.py`)."""
|
||||||
|
if lead_account_id is None:
|
||||||
|
return
|
||||||
|
lead = session.get(Account, lead_account_id)
|
||||||
|
if lead is None or lead.edition_id != edition.id or lead.role is not Role.LEAD:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"code": "position.lead_must_be_referent_account"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED)
|
||||||
def create_position(
|
def create_position(
|
||||||
payload: PositionCreate,
|
payload: PositionCreate,
|
||||||
@@ -35,6 +55,7 @@ def create_position(
|
|||||||
_guard: Account = Depends(require_coordinator),
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Position:
|
) -> Position:
|
||||||
|
_check_lead_account(session, edition, payload.lead_account_id)
|
||||||
position = Position(edition_id=edition.id, **payload.model_dump())
|
position = Position(edition_id=edition.id, **payload.model_dump())
|
||||||
session.add(position)
|
session.add(position)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -80,7 +101,9 @@ def update_position(
|
|||||||
) -> Position:
|
) -> Position:
|
||||||
position = owned_or_404(session.get(Position, position_id), edition.id)
|
position = owned_or_404(session.get(Position, position_id), edition.id)
|
||||||
ensure_can_write_position(account, position)
|
ensure_can_write_position(account, position)
|
||||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
_check_lead_account(session, edition, changes.get("lead_account_id"))
|
||||||
|
for field, value in changes.items():
|
||||||
setattr(position, field, value)
|
setattr(position, field, value)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(position)
|
session.refresh(position)
|
||||||
|
|||||||
@@ -363,6 +363,12 @@ class AccountCreate(ApiModel):
|
|||||||
password: str | None = None
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountUpdate(ApiModel):
|
||||||
|
# Seul le rôle est modifiable, et seulement entre LEAD et VOLUNTEER (pas
|
||||||
|
# de mot de passe en jeu) — voir `api/accounts.py:update_account`.
|
||||||
|
role: Role | None = None
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Notifications par email #
|
# Notifications par email #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.",
|
"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.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.",
|
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.",
|
||||||
|
"position.lead_must_be_referent_account": "Le référent d'un poste doit être un compte référent (rôle LEAD) de cette édition.",
|
||||||
"artist.name_required": "Le nom de l'artiste ne peut pas être vide.",
|
"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.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
|
||||||
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
|
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
|
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
|
||||||
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
|
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
|
||||||
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
|
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
|
||||||
|
"account.role_change_not_allowed": "Un compte coordination ne peut pas changer de rôle ainsi.",
|
||||||
"import.error.empty_file": "Le fichier est vide.",
|
"import.error.empty_file": "Le fichier est vide.",
|
||||||
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
||||||
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
|
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.models import Role
|
||||||
|
|
||||||
|
|
||||||
|
def _url(edition, suffix=""):
|
||||||
|
return f"/editions/{edition.id}/accounts{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_promotes_a_volunteer_account_to_lead(client, edition, make_account):
|
||||||
|
account = make_account(Role.VOLUNTEER, email="v@ex.fr")
|
||||||
|
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["role"] == "LEAD"
|
||||||
|
assert resp.json()["auth_method"] == "magic_link"
|
||||||
|
|
||||||
|
|
||||||
|
def test_demotes_a_lead_account_to_volunteer(client, edition, make_account):
|
||||||
|
account = make_account(Role.LEAD, email="l@ex.fr")
|
||||||
|
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "VOLUNTEER"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["role"] == "VOLUNTEER"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_change_role_of_a_coordinator_account(client, edition, make_account):
|
||||||
|
account = make_account(Role.COORDINATOR, email="c@ex.fr", password="password123")
|
||||||
|
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_promote_a_volunteer_to_coordinator(client, edition, make_account):
|
||||||
|
account = make_account(Role.VOLUNTEER, email="v2@ex.fr")
|
||||||
|
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "COORDINATOR"})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_account_is_404(client, edition):
|
||||||
|
assert (
|
||||||
|
client.patch(_url(edition, f"/{uuid.uuid4()}"), json={"role": "LEAD"}).status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_volunteer_role_forbidden(edition, make_account, as_account):
|
||||||
|
volunteer_account = make_account(Role.VOLUNTEER, email="v3@ex.fr")
|
||||||
|
other = make_account(Role.VOLUNTEER, email="v4@ex.fr")
|
||||||
|
api = as_account(volunteer_account)
|
||||||
|
resp = api.patch(_url(edition, f"/{other.id}"), json={"role": "LEAD"})
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from app.models import Role
|
||||||
|
|
||||||
|
|
||||||
def _url(edition, suffix=""):
|
def _url(edition, suffix=""):
|
||||||
return f"/editions/{edition.id}/positions{suffix}"
|
return f"/editions/{edition.id}/positions{suffix}"
|
||||||
@@ -35,3 +37,75 @@ def test_unknown_edition_is_404(client):
|
|||||||
assert (
|
assert (
|
||||||
client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404
|
client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sets_a_referent(client, edition, make_account):
|
||||||
|
lead = make_account(Role.LEAD, email="referent@ex.fr")
|
||||||
|
created = client.post(
|
||||||
|
_url(edition),
|
||||||
|
json={"name": "Bar", "category": "BAR"},
|
||||||
|
)
|
||||||
|
pid = created.json()["id"]
|
||||||
|
|
||||||
|
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["lead_account_id"] == str(lead.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_referent_must_have_the_lead_role(client, edition, make_account):
|
||||||
|
volunteer_account = make_account(Role.VOLUNTEER, email="v@ex.fr")
|
||||||
|
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
|
||||||
|
pid = created.json()["id"]
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
_url(edition, f"/{pid}"), json={"lead_account_id": str(volunteer_account.id)}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
|
||||||
|
|
||||||
|
|
||||||
|
def test_referent_must_belong_to_the_same_edition(client, edition, session, make_account):
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.models import Edition
|
||||||
|
|
||||||
|
other_edition = Edition(year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4))
|
||||||
|
session.add(other_edition)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(other_edition)
|
||||||
|
|
||||||
|
other_lead = make_account(Role.LEAD, email="stranger@ex.fr")
|
||||||
|
other_lead.edition_id = other_edition.id
|
||||||
|
session.add(other_lead)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
|
||||||
|
pid = created.json()["id"]
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
_url(edition, f"/{pid}"), json={"lead_account_id": str(other_lead.id)}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_referent_is_422(client, edition):
|
||||||
|
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
|
||||||
|
pid = created.json()["id"]
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
_url(edition, f"/{pid}"), json={"lead_account_id": str(uuid.uuid4())}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_the_referent_is_allowed(client, edition, make_account):
|
||||||
|
lead = make_account(Role.LEAD, email="referent2@ex.fr")
|
||||||
|
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
|
||||||
|
pid = created.json()["id"]
|
||||||
|
client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
|
||||||
|
|
||||||
|
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": None})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["lead_account_id"] is None
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { EDITION_ID, api } from './index';
|
import { EDITION_ID, api } from './index';
|
||||||
import type {
|
import type {
|
||||||
AccountCreate,
|
AccountCreate,
|
||||||
|
AccountUpdate,
|
||||||
ArtistCreate,
|
ArtistCreate,
|
||||||
ArtistUpdate,
|
ArtistUpdate,
|
||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
@@ -253,6 +254,15 @@ export function useCreateAccount() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateAccount() {
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: AccountUpdate }) =>
|
||||||
|
api.accounts.update(edition, id, body),
|
||||||
|
onSuccess: invalidate.accounts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteAccount() {
|
export function useDeleteAccount() {
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AccountCreate,
|
AccountCreate,
|
||||||
|
AccountUpdate,
|
||||||
ArtistCreate,
|
ArtistCreate,
|
||||||
ArtistUpdate,
|
ArtistUpdate,
|
||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
@@ -114,6 +115,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
list: (editionId) => request(`${base(editionId)}/accounts`),
|
list: (editionId) => request(`${base(editionId)}/accounts`),
|
||||||
create: (editionId, body: AccountCreate) =>
|
create: (editionId, body: AccountCreate) =>
|
||||||
request(`${base(editionId)}/accounts`, { method: 'POST', body: JSON.stringify(body) }),
|
request(`${base(editionId)}/accounts`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
update: (editionId, id, body: AccountUpdate) =>
|
||||||
|
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
remove: (editionId, id) =>
|
remove: (editionId, id) =>
|
||||||
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
|
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,6 +129,16 @@ function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `lead_account_id` d'un poste : doit désigner un compte référent (LEAD)
|
||||||
|
* existant — sinon le poste se retrouve sans référent utilisable. */
|
||||||
|
function checkLeadAccount(leadAccountId: string | null | undefined): void {
|
||||||
|
if (leadAccountId == null) return;
|
||||||
|
const lead = state.accounts.find((a) => a.id === leadAccountId);
|
||||||
|
if (!lead || lead.role !== 'LEAD') {
|
||||||
|
throw new ApiError(422, 'position.lead_must_be_referent_account');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shiftWindow(day: string, dp: DayPartRead): [string, string] {
|
function shiftWindow(day: string, dp: DayPartRead): [string, string] {
|
||||||
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
|
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
|
||||||
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
|
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
|
||||||
@@ -859,6 +869,18 @@ export function createMockApi(): PlanningApi {
|
|||||||
state.accounts.push(account);
|
state.accounts.push(account);
|
||||||
return delay(account);
|
return delay(account);
|
||||||
},
|
},
|
||||||
|
update: (_edition, id, body) => {
|
||||||
|
const account = state.accounts.find((a) => a.id === id) ?? notFound();
|
||||||
|
if (body.role != null && body.role !== account.role) {
|
||||||
|
if (account.role === 'COORDINATOR' || body.role === 'COORDINATOR') {
|
||||||
|
throw new ApiError(422, 'account.role_change_not_allowed');
|
||||||
|
}
|
||||||
|
account.role = body.role;
|
||||||
|
account.auth_method = 'magic_link';
|
||||||
|
}
|
||||||
|
account.updated_at = touch();
|
||||||
|
return delay(account);
|
||||||
|
},
|
||||||
remove: (_edition, id) => {
|
remove: (_edition, id) => {
|
||||||
state.accounts = state.accounts.filter((a) => a.id !== id);
|
state.accounts = state.accounts.filter((a) => a.id !== id);
|
||||||
return delay(undefined);
|
return delay(undefined);
|
||||||
@@ -1090,6 +1112,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),
|
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),
|
||||||
get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()),
|
get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()),
|
||||||
create: (_edition, body) => {
|
create: (_edition, body) => {
|
||||||
|
checkLeadAccount(body.lead_account_id);
|
||||||
const now = touch();
|
const now = touch();
|
||||||
const position = {
|
const position = {
|
||||||
id: `p${state.seq++}`,
|
id: `p${state.seq++}`,
|
||||||
@@ -1107,6 +1130,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
},
|
},
|
||||||
update: (_edition, id, body) => {
|
update: (_edition, id, body) => {
|
||||||
const p = state.positions.find((x) => x.id === id) ?? notFound();
|
const p = state.positions.find((x) => x.id === id) ?? notFound();
|
||||||
|
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
|
||||||
Object.assign(p, {
|
Object.assign(p, {
|
||||||
...('name' in body ? { name: body.name } : {}),
|
...('name' in body ? { name: body.name } : {}),
|
||||||
...('location' in body ? { location: body.location ?? null } : {}),
|
...('location' in body ? { location: body.location ?? null } : {}),
|
||||||
|
|||||||
@@ -421,6 +421,11 @@ export interface AccountCreate {
|
|||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccountUpdate {
|
||||||
|
/** Seulement entre LEAD et VOLUNTEER — jamais vers/depuis COORDINATOR. */
|
||||||
|
role?: Role;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AccountCreate,
|
AccountCreate,
|
||||||
AccountRead,
|
AccountRead,
|
||||||
|
AccountUpdate,
|
||||||
ArtistCreate,
|
ArtistCreate,
|
||||||
ArtistRead,
|
ArtistRead,
|
||||||
ArtistUpdate,
|
ArtistUpdate,
|
||||||
@@ -73,6 +74,8 @@ export interface PlanningApi {
|
|||||||
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
||||||
list(editionId: string): Promise<AccountRead[]>;
|
list(editionId: string): Promise<AccountRead[]>;
|
||||||
create(editionId: string, body: AccountCreate): Promise<AccountRead>;
|
create(editionId: string, body: AccountCreate): Promise<AccountRead>;
|
||||||
|
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
|
||||||
|
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
|
||||||
remove(editionId: string, id: string): Promise<void>;
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
};
|
};
|
||||||
availability: {
|
availability: {
|
||||||
|
|||||||
@@ -15,10 +15,12 @@
|
|||||||
"assignment.wrong_edition": "Bénévole ou créneau hors de cette édition",
|
"assignment.wrong_edition": "Bénévole ou créneau hors de cette édition",
|
||||||
"shift.ends_not_after_starts": "La fin doit être après le début",
|
"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",
|
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
|
||||||
|
"position.lead_must_be_referent_account": "Le référent d'un poste doit être un compte référent de cette édition",
|
||||||
"account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole",
|
"account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole",
|
||||||
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition",
|
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition",
|
||||||
"account.password_required": "Un compte coordination requiert un mot de passe",
|
"account.password_required": "Un compte coordination requiert un mot de passe",
|
||||||
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe",
|
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe",
|
||||||
|
"account.role_change_not_allowed": "Un compte coordination ne peut pas changer de rôle ainsi",
|
||||||
"auth.invalid_credentials": "Email ou mot de passe incorrect",
|
"auth.invalid_credentials": "Email ou mot de passe incorrect",
|
||||||
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
|
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
|
||||||
"role.coordinator": "Coordination",
|
"role.coordinator": "Coordination",
|
||||||
@@ -234,6 +236,10 @@
|
|||||||
"config.vol.access.revoke": "Révoquer",
|
"config.vol.access.revoke": "Révoquer",
|
||||||
"config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?",
|
"config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?",
|
||||||
"config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
|
"config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
|
||||||
|
"config.vol.field.referentPositions": "Référent des postes",
|
||||||
|
"config.vol.referentHint": "Cocher un poste crée ou met à niveau son accès de connexion en référent : à sa prochaine connexion, il verra l'écran référent (son équipe, le pointage) au lieu de l'écran bénévole. Décocher retire seulement le poste, sans jamais rétrograder l'accès automatiquement.",
|
||||||
|
"config.vol.referentIsCoordinator": "Ce bénévole a un compte coordination : il a déjà accès à tous les postes, inutile de le désigner référent.",
|
||||||
|
"config.vol.referentNeedsEmail": "Renseignez un email sur la fiche pour pouvoir désigner ce bénévole référent.",
|
||||||
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
|
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
|
||||||
"config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.",
|
"config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.",
|
||||||
"config.vol.create": "CRÉER LE BÉNÉVOLE",
|
"config.vol.create": "CRÉER LE BÉNÉVOLE",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
import { ApiError } from '@/api';
|
import { ApiError } from '@/api';
|
||||||
|
import { roleLabelKey } from '@/auth/roles';
|
||||||
import {
|
import {
|
||||||
useAccounts,
|
useAccounts,
|
||||||
useAnalyzeImport,
|
useAnalyzeImport,
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
useExecuteImport,
|
useExecuteImport,
|
||||||
usePositions,
|
usePositions,
|
||||||
useReplaceAvailability,
|
useReplaceAvailability,
|
||||||
|
useUpdateAccount,
|
||||||
|
useUpdatePosition,
|
||||||
useUpdateVolunteer,
|
useUpdateVolunteer,
|
||||||
useVolunteerAvailability,
|
useVolunteerAvailability,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
@@ -115,6 +118,9 @@ function VolunteerAccessSection({
|
|||||||
<div className="flex items-center justify-between gap-2 border-2 border-ink bg-cream p-[9px]">
|
<div className="flex items-center justify-between gap-2 border-2 border-ink bg-cream p-[9px]">
|
||||||
<span className="min-w-0 truncate font-mono text-[10px]">
|
<span className="min-w-0 truncate font-mono text-[10px]">
|
||||||
{t('config.vol.access.active', { email: account.email })}
|
{t('config.vol.access.active', { email: account.email })}
|
||||||
|
<span className="ml-[6px] border-[1.5px] border-ink bg-yellow px-1 py-[1px] font-display text-[8px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t(roleLabelKey(account.role))}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -152,6 +158,116 @@ function VolunteerAccessSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
|
||||||
|
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
|
||||||
|
* à sa prochaine connexion il verra l'écran référent, pas l'écran bénévole
|
||||||
|
* (voir `auth/roles.ts:homePath`). Décocher retire juste son nom du poste,
|
||||||
|
* sans jamais rétrograder son accès automatiquement.
|
||||||
|
*/
|
||||||
|
function VolunteerReferentSection({
|
||||||
|
volunteerId,
|
||||||
|
email,
|
||||||
|
}: {
|
||||||
|
volunteerId: string;
|
||||||
|
email: string | null;
|
||||||
|
}) {
|
||||||
|
const accounts = useAccounts();
|
||||||
|
const positions = usePositions();
|
||||||
|
const createAccount = useCreateAccount();
|
||||||
|
const updateAccount = useUpdateAccount();
|
||||||
|
const updatePosition = useUpdatePosition();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const account = accounts.data?.find((a) => a.volunteer_id === volunteerId);
|
||||||
|
const isCoordinator = account?.role === 'COORDINATOR';
|
||||||
|
const options = useMemo(() => positionOptions(positions.data), [positions.data]);
|
||||||
|
const referentIds = (positions.data ?? [])
|
||||||
|
.filter((p) => account && p.lead_account_id === account.id)
|
||||||
|
.map((p) => p.id);
|
||||||
|
|
||||||
|
const ensureLeadAccountId = async (): Promise<string> => {
|
||||||
|
if (account) {
|
||||||
|
if (account.role !== 'LEAD') {
|
||||||
|
await updateAccount.mutateAsync({ id: account.id, body: { role: 'LEAD' } });
|
||||||
|
}
|
||||||
|
return account.id;
|
||||||
|
}
|
||||||
|
if (!email) throw new ApiError(422, 'config.vol.referentNeedsEmail');
|
||||||
|
const created = await createAccount.mutateAsync({
|
||||||
|
email,
|
||||||
|
role: 'LEAD',
|
||||||
|
volunteer_id: volunteerId,
|
||||||
|
});
|
||||||
|
return created.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (nextIds: string[]) => {
|
||||||
|
const added = nextIds.filter((id) => !referentIds.includes(id));
|
||||||
|
const removed = referentIds.filter((id) => !nextIds.includes(id));
|
||||||
|
if (added.length === 0 && removed.length === 0) return;
|
||||||
|
setError(null);
|
||||||
|
setPending(true);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
let leadAccountId: string | null = null;
|
||||||
|
if (added.length > 0) leadAccountId = await ensureLeadAccountId();
|
||||||
|
for (const id of added) {
|
||||||
|
await updatePosition.mutateAsync({ id, body: { lead_account_id: leadAccountId } });
|
||||||
|
}
|
||||||
|
for (const id of removed) {
|
||||||
|
await updatePosition.mutateAsync({ id, body: { lead_account_id: null } });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? t(err.code) : t('error.generic'));
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = (
|
||||||
|
<>
|
||||||
|
{t('config.vol.field.referentPositions')} <HelpTip text={t('config.vol.referentHint')} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isCoordinator) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{label}</FieldLabel>
|
||||||
|
<div className="font-mono text-[10px] opacity-60">
|
||||||
|
{t('config.vol.referentIsCoordinator')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BadgeWishPicker
|
||||||
|
label={label}
|
||||||
|
options={options}
|
||||||
|
selected={referentIds}
|
||||||
|
onChange={handleChange}
|
||||||
|
emptyLabel={t('config.vol.noPositions')}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
{referentIds.length === 0 && !email ? (
|
||||||
|
<div className="mt-1 font-mono text-[9.5px] opacity-60">
|
||||||
|
{t('config.vol.access.needEmail')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-1 border-2 border-ink bg-orange p-[7px] font-display text-[10px] font-bold">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Sélecteur de vœux avec recherche (bénévoles : liste potentiellement
|
/** Sélecteur de vœux avec recherche (bénévoles : liste potentiellement
|
||||||
* longue) — options déjà triées par ordre alphabétique par l'appelant. */
|
* longue) — options déjà triées par ordre alphabétique par l'appelant. */
|
||||||
function WishPicker({
|
function WishPicker({
|
||||||
@@ -235,12 +351,14 @@ function BadgeWishPicker({
|
|||||||
selected,
|
selected,
|
||||||
onChange,
|
onChange,
|
||||||
emptyLabel,
|
emptyLabel,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
options: WishOption[];
|
options: WishOption[];
|
||||||
selected: string[];
|
selected: string[];
|
||||||
onChange: (ids: string[]) => void;
|
onChange: (ids: string[]) => void;
|
||||||
emptyLabel: string;
|
emptyLabel: string;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const toggle = (id: string) =>
|
const toggle = (id: string) =>
|
||||||
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
|
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
|
||||||
@@ -250,7 +368,7 @@ function BadgeWishPicker({
|
|||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
|
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-[6px]">
|
<div className={cx('flex flex-wrap gap-[6px]', disabled && 'pointer-events-none opacity-50')}>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<Chip key={o.id} active={selected.includes(o.id)} onClick={() => toggle(o.id)}>
|
<Chip key={o.id} active={selected.includes(o.id)} onClick={() => toggle(o.id)}>
|
||||||
{o.hint ? `${o.hint} · ${o.label}` : o.label}
|
{o.hint ? `${o.hint} · ${o.label}` : o.label}
|
||||||
@@ -489,6 +607,7 @@ export function ConfigVolunteers() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||||
|
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
|
||||||
|
|
||||||
<BadgeWishPicker
|
<BadgeWishPicker
|
||||||
label={t('config.vol.field.artists')}
|
label={t('config.vol.field.artists')}
|
||||||
|
|||||||
Reference in New Issue
Block a user