Files
benevoles/backend/app/api/volunteers.py
T
AdamThomasandClaude Sonnet 5 ced922f785 Étape 7 : authentification par lien magique + JWT + rôles
- app/auth/ : jetons (lien magique aléatoire haché SHA-256, JWT HS256),
  dépendances get_current_account / current_account_in_edition / require_roles
  + require_coordinator / require_staff, et ensure_can_write_position/_shift
- Endpoints app/api/auth.py : POST .../auth/magic-link (202 constant, pas de
  divulgation), POST .../auth/verify (JWT, jeton à usage unique), GET /auth/me
- Envoi d'email stubé : le lien est journalisé (logger app.auth, identifiants
  seulement, aucune donnée nominative)
- app/api/accounts.py : CRUD des comptes réservé à la COORDINATION
- Rôles appliqués sur toute l'API :
  COORDINATION accès complet ; RÉFÉRENT lecture globale + écriture sur ses
  seuls postes/créneaux/affectations ; BÉNÉVOLE sa fiche et ses affectations,
  aucune écriture
- imports.py aligné sur la dépendance get_edition (404 avant 403)
- Secret JWT porté à >= 32 octets (config, .env.example, docker-compose)
- seed : crée un compte VOLUNTEER pour chaque bénévole avec email (104 comptes)
- conftest : fixtures make_account / coordinator / as_account / raw_client ;
  la suite existante s'exécute en COORDINATION
- 13 tests d'authentification + de rôles ; 119 tests au total ; alembic check
  toujours vert (Account / MagicLinkToken déjà dans la migration 0001)

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

156 lines
5.1 KiB
Python

"""CRUD des bénévoles. La suppression est un effacement complet (RGPD)."""
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, replace_links, resolve_skills
from app.api.deps import get_edition
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
from app.auth.deps import (
current_account_in_edition,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.models import Account, Edition, Role, Skill, Volunteer, VolunteerSkill
router = APIRouter(
prefix="/editions/{edition_id}/volunteers", tags=["volunteers"]
)
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
if account.role in (Role.COORDINATOR, Role.LEAD):
return
if account.volunteer_id == volunteer_id:
return
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
def _skill_codes(session: Session, volunteer_id: uuid.UUID) -> list[str]:
return sorted(
session.exec(
select(Skill.code)
.join(VolunteerSkill, VolunteerSkill.skill_id == Skill.id)
.where(VolunteerSkill.volunteer_id == volunteer_id)
)
)
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead(
**volunteer.model_dump(),
skill_codes=_skill_codes(session, volunteer.id),
)
def _set_skills(
session: Session, volunteer: Volunteer, codes: list[str]
) -> None:
skills = resolve_skills(session, volunteer.edition_id, codes)
replace_links(
session,
VolunteerSkill,
filter_column=VolunteerSkill.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "skill_id": s.id} for s in skills
],
)
@router.post("", response_model=VolunteerRead, status_code=status.HTTP_201_CREATED)
def create_volunteer(
payload: VolunteerCreate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = Volunteer(
edition_id=edition.id,
**payload.model_dump(exclude={"skill_codes"}),
)
session.add(volunteer)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
_set_skills(session, volunteer, payload.skill_codes)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.get("", response_model=list[VolunteerRead])
def list_volunteers(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[VolunteerRead]:
volunteers = session.exec(
select(Volunteer)
.where(Volunteer.edition_id == edition.id)
.order_by(Volunteer.last_name, Volunteer.first_name)
)
return [_read(session, v) for v in volunteers]
@router.get("/{volunteer_id}", response_model=VolunteerRead)
def get_volunteer(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> VolunteerRead:
_ensure_can_read(account, volunteer_id)
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
return _read(session, volunteer)
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
def update_volunteer(
volunteer_id: uuid.UUID,
payload: VolunteerUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
changes = payload.model_dump(exclude_unset=True)
skill_codes = changes.pop("skill_codes", None)
for field, value in changes.items():
setattr(volunteer, field, value)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
if skill_codes is not None:
_set_skills(session, volunteer, skill_codes)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.delete("/{volunteer_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_volunteer(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
# Effacement complet : la cascade base supprime disponibilités,
# affectations, liens de compétence et compte éventuel.
session.delete(volunteer)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)