Files
benevoles/backend/app/api/accounts.py
T
AdamThomasandClaude Sonnet 5 8fcc22115f 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
2026-09-15 23:19:56 +02:00

123 lines
4.2 KiB
Python

"""Gestion des comptes par la coordination (créer un référent, un bénévole
connecté…)."""
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
from app.api.deps import get_edition
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
from app.models import Account, Edition, Role, Volunteer
router = APIRouter(
prefix="/editions/{edition_id}/accounts", tags=["accounts"]
)
@router.post("", response_model=AccountRead, status_code=status.HTTP_201_CREATED)
def create_account(
payload: AccountCreate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Account:
if payload.volunteer_id is not None:
volunteer = session.get(Volunteer, payload.volunteer_id)
if volunteer is None or volunteer.edition_id != edition.id:
raise HTTPException(
status_code=422, detail={"code": "account.volunteer_wrong_edition"}
)
# Deux systèmes de connexion : mot de passe pour la COORDINATION,
# lien magique pour les autres.
if payload.role is Role.COORDINATOR:
if not payload.password or len(payload.password) < MIN_LENGTH:
raise HTTPException(
status_code=422,
detail={"code": "account.password_required", "min": MIN_LENGTH},
)
password_hash = hash_password(payload.password)
else:
if payload.password is not None:
raise HTTPException(
status_code=422,
detail={"code": "account.password_not_allowed"},
)
password_hash = None
account = Account(
edition_id=edition.id,
email=payload.email.strip(),
role=payload.role,
volunteer_id=payload.volunteer_id,
password_hash=password_hash,
)
session.add(account)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "account.duplicate"}
) from exc
session.refresh(account)
return account
@router.get("", response_model=list[AccountRead])
def list_accounts(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[Account]:
return list(
session.exec(
select(Account)
.where(Account.edition_id == edition.id)
.order_by(Account.email)
)
)
@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,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
account = owned_or_404(session.get(Account, account_id), edition.id)
session.delete(account)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)