Files
benevoles/backend/app/api/accounts.py
T
AdamThomasandClaude Sonnet 5 8a879ca5c3 Étape 8 : connexion par mot de passe pour la coordination
Deux systèmes d'authentification, une même session JWT :
- COORDINATION : login e-mail + mot de passe (bcrypt), POST .../auth/login ;
  POST .../auth/change-password pour le compte courant
- RÉFÉRENTS / BÉNÉVOLES : lien magique inchangé, mais refusé pour un compte
  qui a un mot de passe

- Account.password_hash (nullable) + migration 0002 ; propriété auth_method
  ("password" / "magic_link") exposée dans AccountRead
- app/auth/passwords.py : hash/verify bcrypt, longueur mini 10
- Création de compte : password requis pour COORDINATOR, interdit sinon
- seed : la coordination de démo a un mot de passe, affiché dans le résumé
- 12 tests (login ok/ko, séparation des deux systèmes, change-password,
  validation à la création) ; 129 tests au total ; alembic check vert

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-09 23:42:31 +02:00

99 lines
3.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
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.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)