diff --git a/CLAUDE.md b/CLAUDE.md index 1941425..eb2ba3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,15 @@ les tables sauf les tables d'association. unique et éditable indépendamment par chacun). `CHECK (volunteer_id <> companion_id)` : un bénévole ne peut pas se souhaiter lui-même. - **`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 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 @@ -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 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/`) 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 | | 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 | +| 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 de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/accounts.py b/backend/app/api/accounts.py index 041ef03..7dabecd 100644 --- a/backend/app/api/accounts.py +++ b/backend/app/api/accounts.py @@ -9,7 +9,7 @@ from sqlmodel import Session, select from app.api._common import owned_or_404 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.passwords import MIN_LENGTH, hash_password 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) def delete_account( account_id: uuid.UUID, diff --git a/backend/app/api/positions.py b/backend/app/api/positions.py index 9b70ca9..38c6756 100644 --- a/backend/app/api/positions.py +++ b/backend/app/api/positions.py @@ -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 -from fastapi import APIRouter, Depends, Response, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlmodel import Session, select from app.api._common import owned_or_404 @@ -21,13 +26,28 @@ from app.auth.deps import ( require_staff, ) from app.db import get_session -from app.models import Account, Edition, Position +from app.models import Account, Edition, Position, Role router = APIRouter( 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) def create_position( payload: PositionCreate, @@ -35,6 +55,7 @@ def create_position( _guard: Account = Depends(require_coordinator), session: Session = Depends(get_session), ) -> Position: + _check_lead_account(session, edition, payload.lead_account_id) position = Position(edition_id=edition.id, **payload.model_dump()) session.add(position) session.flush() @@ -80,7 +101,9 @@ def update_position( ) -> Position: position = owned_or_404(session.get(Position, position_id), edition.id) 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) session.commit() session.refresh(position) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index bf53e4a..e8c4640 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -363,6 +363,12 @@ class AccountCreate(ApiModel): 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 # # --------------------------------------------------------------------------- # diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 065cf82..e3d113b 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -10,6 +10,7 @@ "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.", + "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.duplicate": "Un artiste porte déjà ce nom à cette date dans l'é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.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.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.encoding": "L'encodage du fichier n'est pas reconnu.", "import.error.unreadable_file": "Fichier illisible ou format non pris en charge.", diff --git a/backend/tests/test_api_accounts.py b/backend/tests/test_api_accounts.py new file mode 100644 index 0000000..5fec592 --- /dev/null +++ b/backend/tests/test_api_accounts.py @@ -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 diff --git a/backend/tests/test_api_positions.py b/backend/tests/test_api_positions.py index 4d51e7c..d1dfab2 100644 --- a/backend/tests/test_api_positions.py +++ b/backend/tests/test_api_positions.py @@ -1,5 +1,7 @@ import uuid +from app.models import Role + def _url(edition, suffix=""): return f"/editions/{edition.id}/positions{suffix}" @@ -35,3 +37,75 @@ def test_unknown_edition_is_404(client): assert ( 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 diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 7bbd2c1..a007e33 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -8,6 +8,7 @@ import { import { EDITION_ID, api } from './index'; import type { AccountCreate, + AccountUpdate, ArtistCreate, ArtistUpdate, 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() { const invalidate = useInvalidate(); return useMutation({ diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 809bc06..c5f4b63 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -1,5 +1,6 @@ import type { AccountCreate, + AccountUpdate, ArtistCreate, ArtistUpdate, AssignmentCreate, @@ -114,6 +115,8 @@ export function createHttpApi(baseUrl: string): PlanningApi { list: (editionId) => request(`${base(editionId)}/accounts`), create: (editionId, body: AccountCreate) => 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) => request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }), }, diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 5286e79..8ea987a 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -129,6 +129,16 @@ function resolveCompanionWishes(ids: string[], selfId?: string): string[] { 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] { const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day; return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`]; @@ -859,6 +869,18 @@ export function createMockApi(): PlanningApi { state.accounts.push(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) => { state.accounts = state.accounts.filter((a) => a.id !== id); return delay(undefined); @@ -1090,6 +1112,7 @@ export function createMockApi(): PlanningApi { delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))), get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()), create: (_edition, body) => { + checkLeadAccount(body.lead_account_id); const now = touch(); const position = { id: `p${state.seq++}`, @@ -1107,6 +1130,7 @@ export function createMockApi(): PlanningApi { }, update: (_edition, id, body) => { const p = state.positions.find((x) => x.id === id) ?? notFound(); + if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id); Object.assign(p, { ...('name' in body ? { name: body.name } : {}), ...('location' in body ? { location: body.location ?? null } : {}), diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index b07067b..b775e5f 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -421,6 +421,11 @@ export interface AccountCreate { password?: string | null; } +export interface AccountUpdate { + /** Seulement entre LEAD et VOLUNTEER — jamais vers/depuis COORDINATOR. */ + role?: Role; +} + export interface TokenResponse { access_token: string; token_type: string; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e249ebe..f4e1bc6 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,6 +1,7 @@ import type { AccountCreate, AccountRead, + AccountUpdate, ArtistCreate, ArtistRead, ArtistUpdate, @@ -73,6 +74,8 @@ export interface PlanningApi { /** Comptes de connexion (mot de passe ou lien magique) de l'édition. */ list(editionId: string): Promise; create(editionId: string, body: AccountCreate): Promise; + /** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */ + update(editionId: string, id: string, body: AccountUpdate): Promise; remove(editionId: string, id: string): Promise; }; availability: { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 9b1f003..867f1a7 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -15,10 +15,12 @@ "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.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.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_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_token": "Ce lien de connexion est invalide ou expiré", "role.coordinator": "Coordination", @@ -234,6 +236,10 @@ "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.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.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", diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index afe63ad..bbaf904 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { ApiError } from '@/api'; +import { roleLabelKey } from '@/auth/roles'; import { useAccounts, useAnalyzeImport, @@ -13,6 +14,8 @@ import { useExecuteImport, usePositions, useReplaceAvailability, + useUpdateAccount, + useUpdatePosition, useUpdateVolunteer, useVolunteerAvailability, useVolunteers, @@ -115,6 +118,9 @@ function VolunteerAccessSection({
{t('config.vol.access.active', { email: account.email })} + + {t(roleLabelKey(account.role))} +