Files
AdamThomasandClaude Opus 5.5 05cdb0276b
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 11s
Lien de connexion directe permanent dans le récapitulatif de planning
{lien_connexion} devient un lien permanent (réutilisable, sans expiration,
signé HMAC, rien de stocké) au lieu d'un lien magique de 30 min à usage
unique ou d'un simple lien vers le site. Compte bénévole créé à l'envoi
s'il manque ; révocation par « Renouveler le lien de connexion » sur la
fiche (Account.login_link_version, migration 0031) ; jamais pour la
coordination.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:01:03 +01:00

141 lines
4.9 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.post("/{account_id}/reset-login-link", response_model=AccountRead)
def reset_login_link(
account_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Account:
"""Révoque le lien de connexion permanent du compte (`app/auth/
permanent_link.py`) : tous les liens déjà envoyés cessent de
fonctionner ; le prochain récapitulatif portera un nouveau lien. Ex.
email transféré, téléphone perdu."""
account = owned_or_404(session.get(Account, account_id), edition.id)
account.login_link_version += 1
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)