É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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c9dda65361
commit
ced922f785
+3
-2
@@ -5,5 +5,6 @@ POSTGRES_USER=benevoles
|
|||||||
POSTGRES_PASSWORD=benevoles
|
POSTGRES_PASSWORD=benevoles
|
||||||
POSTGRES_DB=benevoles
|
POSTGRES_DB=benevoles
|
||||||
|
|
||||||
# Secret de signature des jetons (lien magique / session). À régénérer en prod.
|
# Secret de signature des jetons (lien magique / session), >= 32 octets.
|
||||||
JWT_SECRET=dev-only-change-me
|
# À régénérer en production : openssl rand -hex 32
|
||||||
|
JWT_SECRET=dev-insecure-secret-change-me-please-32b
|
||||||
|
|||||||
@@ -95,17 +95,40 @@ Conventions techniques :
|
|||||||
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
|
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
|
||||||
`tzdata`). `app.models.local_now()` pour les horodatages.
|
`tzdata`). `app.models.local_now()` pour les horodatages.
|
||||||
|
|
||||||
### Rôles et accès
|
### Rôles et accès (`app/auth/`)
|
||||||
|
|
||||||
- **`COORDINATOR`** : accès complet, construit et publie les plannings.
|
- **`COORDINATOR`** : accès complet, construit et publie les plannings.
|
||||||
- **`LEAD`** (référent) : lecture sur toute l'édition, écriture limitée à ses
|
- **`LEAD`** (référent) : lecture sur toute l'édition, écriture limitée aux
|
||||||
propres postes.
|
postes dont il est `lead_account_id` (et à leurs créneaux et affectations).
|
||||||
- **`VOLUNTEER`** : accès à ses seules données.
|
- **`VOLUNTEER`** : lecture de sa seule fiche et de ses seules affectations ;
|
||||||
|
aucune écriture.
|
||||||
|
|
||||||
Authentification par lien magique envoyé par email, sans mot de passe. L'envoi
|
Matrice appliquée par les endpoints :
|
||||||
d'email est **stubé** pour l'instant (le lien est écrit dans les logs). Un
|
|
||||||
bénévole sans email n'a pas de `Account` et ne se connecte pas ; il est géré
|
| Ressource | COORDINATOR | LEAD | VOLUNTEER |
|
||||||
entièrement par la coordination.
|
|-----------|-------------|------|-----------|
|
||||||
|
| `volunteers` liste/écriture | ✔ | liste seule | — |
|
||||||
|
| `volunteers/{id}` lecture | ✔ | ✔ | soi-même |
|
||||||
|
| `positions`/`shifts` lecture | ✔ | ✔ | — |
|
||||||
|
| `positions`/`shifts` écriture | ✔ | ses postes | — |
|
||||||
|
| `assignments` écriture | ✔ | ses postes | — |
|
||||||
|
| `assignments` lecture | ✔ | ✔ | les siennes |
|
||||||
|
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
||||||
|
|
||||||
|
**Authentification** : lien magique + session JWT (`Bearer`), sans mot de passe.
|
||||||
|
|
||||||
|
1. `POST /editions/{id}/auth/magic-link` `{email}` → réponse `202` identique
|
||||||
|
qu'un compte existe ou non. Si oui, un `MagicLinkToken` est créé (jeton
|
||||||
|
aléatoire, **haché** en base, TTL `magic_link_ttl_minutes`).
|
||||||
|
2. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`,
|
||||||
|
niveau INFO, `jeton=…` — identifiants seulement, aucune donnée nominative).
|
||||||
|
3. `POST /editions/{id}/auth/verify` `{token}` → JWT HS256
|
||||||
|
(`session_ttl_hours`) + `AccountRead`. Jeton à usage unique.
|
||||||
|
4. `GET /auth/me` → compte courant. En-tête `Authorization: Bearer <jwt>`.
|
||||||
|
|
||||||
|
Un bénévole sans email n'a pas de `Account` et ne se connecte pas ; il est géré
|
||||||
|
entièrement par la coordination. La coordination crée les comptes via
|
||||||
|
`POST /editions/{id}/accounts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -188,8 +211,10 @@ pointage sur site · fonctions d'IA.
|
|||||||
│ │ ├── db.py # engine + session
|
│ │ ├── db.py # engine + session
|
||||||
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs
|
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs
|
||||||
│ │ ├── api/ # routers + schemas Pydantic + deps
|
│ │ ├── api/ # routers + schemas Pydantic + deps
|
||||||
│ │ │ # health, imports, volunteers, positions,
|
│ │ │ # health, auth, accounts, imports,
|
||||||
│ │ │ # shifts, assignments, matrix
|
│ │ │ # volunteers, positions, shifts,
|
||||||
|
│ │ │ # assignments, matrix
|
||||||
|
│ │ ├── auth/ # lien magique + JWT + dépendances de rôle
|
||||||
│ │ ├── importing/ # import CSV/Excel (parser, values, service)
|
│ │ ├── importing/ # import CSV/Excel (parser, values, service)
|
||||||
│ │ ├── models/ # tables SQLModel
|
│ │ ├── models/ # tables SQLModel
|
||||||
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
||||||
@@ -268,10 +293,11 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 3 | Import CSV/Excel (analyse + exécution, rapport, doublons, simulation) | validée |
|
| 3 | Import CSV/Excel (analyse + exécution, rapport, doublons, simulation) | validée |
|
||||||
| 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | validée |
|
| 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | validée |
|
||||||
| 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée |
|
| 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée |
|
||||||
| 6 | Jeu de données de démonstration (`app/seed.py`) | à valider |
|
| 6 | Jeu de données de démonstration (`app/seed.py`) | validée |
|
||||||
|
| 7 | Authentification : lien magique + JWT + application des rôles | à valider |
|
||||||
|
|
||||||
Premier incrément backend terminé. **Reste hors périmètre** (modèle prêt à les
|
Premier incrément backend + authentification terminés. **Reste hors
|
||||||
accueillir) : authentification par lien magique (`Account` + `MagicLinkToken`
|
périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien
|
||||||
existent, endpoints non encore protégés), solveur OR-Tools, envoi
|
magique est journalisé, pas envoyé), solveur OR-Tools, constructeur de
|
||||||
d'emails/SMS, constructeur de formulaire, pointage, IA. Le frontend
|
formulaire, pointage sur site, IA. Le frontend (React/Vite/TS/Tailwind) reste
|
||||||
(React/Vite/TS/Tailwind) reste à démarrer.
|
à démarrer.
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""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.db import get_session
|
||||||
|
from app.models import Account, Edition, 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"}
|
||||||
|
)
|
||||||
|
account = Account(
|
||||||
|
edition_id=edition.id,
|
||||||
|
email=payload.email.strip(),
|
||||||
|
role=payload.role,
|
||||||
|
volunteer_id=payload.volunteer_id,
|
||||||
|
)
|
||||||
|
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)
|
||||||
@@ -17,11 +17,18 @@ from app.api.schemas import (
|
|||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AssignmentWriteResult,
|
AssignmentWriteResult,
|
||||||
)
|
)
|
||||||
|
from app.auth.deps import (
|
||||||
|
current_account_in_edition,
|
||||||
|
ensure_can_write_shift,
|
||||||
|
require_staff,
|
||||||
|
)
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models import (
|
from app.models import (
|
||||||
ACTIVE_ASSIGNMENT_STATUSES,
|
ACTIVE_ASSIGNMENT_STATUSES,
|
||||||
|
Account,
|
||||||
Assignment,
|
Assignment,
|
||||||
Edition,
|
Edition,
|
||||||
|
Role,
|
||||||
Shift,
|
Shift,
|
||||||
Volunteer,
|
Volunteer,
|
||||||
)
|
)
|
||||||
@@ -50,12 +57,25 @@ def _pair_or_422(
|
|||||||
return volunteer, shift
|
return volunteer, shift
|
||||||
|
|
||||||
|
|
||||||
|
def _forbidden() -> HTTPException:
|
||||||
|
return HTTPException(status_code=403, detail={"code": "error.forbidden"})
|
||||||
|
|
||||||
|
|
||||||
|
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 _forbidden()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/validate", response_model=AssignmentValidateResult, tags=["assignments"]
|
"/validate", response_model=AssignmentValidateResult, tags=["assignments"]
|
||||||
)
|
)
|
||||||
def validate_assignment(
|
def validate_assignment(
|
||||||
payload: AssignmentValidateRequest,
|
payload: AssignmentValidateRequest,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_staff),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> AssignmentValidateResult:
|
) -> AssignmentValidateResult:
|
||||||
volunteer, shift = _pair_or_422(
|
volunteer, shift = _pair_or_422(
|
||||||
@@ -77,11 +97,13 @@ def validate_assignment(
|
|||||||
def create_assignment(
|
def create_assignment(
|
||||||
payload: AssignmentCreate,
|
payload: AssignmentCreate,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> AssignmentWriteResult:
|
) -> AssignmentWriteResult:
|
||||||
volunteer, shift = _pair_or_422(
|
volunteer, shift = _pair_or_422(
|
||||||
session, edition.id, payload.volunteer_id, payload.shift_id
|
session, edition.id, payload.volunteer_id, payload.shift_id
|
||||||
)
|
)
|
||||||
|
ensure_can_write_shift(account, session, shift)
|
||||||
existing = session.exec(
|
existing = session.exec(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.where(Assignment.volunteer_id == volunteer.id)
|
.where(Assignment.volunteer_id == volunteer.id)
|
||||||
@@ -132,8 +154,14 @@ def list_assignments(
|
|||||||
volunteer_id: uuid.UUID | None = None,
|
volunteer_id: uuid.UUID | None = None,
|
||||||
shift_id: uuid.UUID | None = None,
|
shift_id: uuid.UUID | None = None,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[Assignment]:
|
) -> list[Assignment]:
|
||||||
|
if account.role is Role.VOLUNTEER:
|
||||||
|
# Un bénévole ne voit que ses propres affectations.
|
||||||
|
if volunteer_id not in (None, account.volunteer_id):
|
||||||
|
raise _forbidden()
|
||||||
|
volunteer_id = account.volunteer_id
|
||||||
stmt = select(Assignment).where(Assignment.edition_id == edition.id)
|
stmt = select(Assignment).where(Assignment.edition_id == edition.id)
|
||||||
if volunteer_id is not None:
|
if volunteer_id is not None:
|
||||||
stmt = stmt.where(Assignment.volunteer_id == volunteer_id)
|
stmt = stmt.where(Assignment.volunteer_id == volunteer_id)
|
||||||
@@ -146,9 +174,14 @@ def list_assignments(
|
|||||||
def get_assignment(
|
def get_assignment(
|
||||||
assignment_id: uuid.UUID,
|
assignment_id: uuid.UUID,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Assignment:
|
) -> Assignment:
|
||||||
return owned_or_404(session.get(Assignment, assignment_id), edition.id)
|
assignment = owned_or_404(
|
||||||
|
session.get(Assignment, assignment_id), edition.id
|
||||||
|
)
|
||||||
|
_ensure_can_read(account, assignment.volunteer_id)
|
||||||
|
return assignment
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{assignment_id}", response_model=AssignmentWriteResult)
|
@router.patch("/{assignment_id}", response_model=AssignmentWriteResult)
|
||||||
@@ -156,11 +189,15 @@ def update_assignment(
|
|||||||
assignment_id: uuid.UUID,
|
assignment_id: uuid.UUID,
|
||||||
payload: AssignmentUpdate,
|
payload: AssignmentUpdate,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> AssignmentWriteResult:
|
) -> AssignmentWriteResult:
|
||||||
assignment = owned_or_404(
|
assignment = owned_or_404(
|
||||||
session.get(Assignment, assignment_id), edition.id
|
session.get(Assignment, assignment_id), edition.id
|
||||||
)
|
)
|
||||||
|
ensure_can_write_shift(
|
||||||
|
account, session, session.get(Shift, assignment.shift_id)
|
||||||
|
)
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
for field, value in changes.items():
|
for field, value in changes.items():
|
||||||
setattr(assignment, field, value)
|
setattr(assignment, field, value)
|
||||||
@@ -197,11 +234,15 @@ def update_assignment(
|
|||||||
def delete_assignment(
|
def delete_assignment(
|
||||||
assignment_id: uuid.UUID,
|
assignment_id: uuid.UUID,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
assignment = owned_or_404(
|
assignment = owned_or_404(
|
||||||
session.get(Assignment, assignment_id), edition.id
|
session.get(Assignment, assignment_id), edition.id
|
||||||
)
|
)
|
||||||
|
ensure_can_write_shift(
|
||||||
|
account, session, session.get(Shift, assignment.shift_id)
|
||||||
|
)
|
||||||
session.delete(assignment)
|
session.delete(assignment)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Endpoints d'authentification : lien magique, vérification, profil courant."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.api.schemas import (
|
||||||
|
AccountRead,
|
||||||
|
MagicLinkRequest,
|
||||||
|
TokenResponse,
|
||||||
|
VerifyRequest,
|
||||||
|
)
|
||||||
|
from app.auth.deps import get_current_account
|
||||||
|
from app.auth.tokens import create_access_token, hash_token, new_raw_token
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models import Account, MagicLinkToken, local_now
|
||||||
|
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
logger = logging.getLogger("app.auth")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/editions/{edition_id}/auth/magic-link",
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
def request_magic_link(
|
||||||
|
edition_id: uuid.UUID,
|
||||||
|
payload: MagicLinkRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Émet un lien magique si un compte correspond. Réponse identique dans
|
||||||
|
tous les cas (pas de divulgation de l'existence d'un compte)."""
|
||||||
|
account = session.exec(
|
||||||
|
select(Account)
|
||||||
|
.where(Account.edition_id == edition_id)
|
||||||
|
.where(func.lower(Account.email) == payload.email.strip().lower())
|
||||||
|
).first()
|
||||||
|
if account is not None:
|
||||||
|
raw = new_raw_token()
|
||||||
|
token = MagicLinkToken(
|
||||||
|
account_id=account.id,
|
||||||
|
token_hash=hash_token(raw),
|
||||||
|
expires_at=local_now()
|
||||||
|
+ dt.timedelta(minutes=get_settings().magic_link_ttl_minutes),
|
||||||
|
)
|
||||||
|
session.add(token)
|
||||||
|
session.commit()
|
||||||
|
# STUB : en production, ce lien part par email. Ici on le journalise
|
||||||
|
# (identifiants uniquement, aucune donnée nominative).
|
||||||
|
logger.info(
|
||||||
|
"STUB lien magique — compte=%s édition=%s expire=%s jeton=%s",
|
||||||
|
account.id,
|
||||||
|
edition_id,
|
||||||
|
token.expires_at.isoformat(),
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
return {"status": "accepted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/editions/{edition_id}/auth/verify", response_model=TokenResponse)
|
||||||
|
def verify_magic_link(
|
||||||
|
edition_id: uuid.UUID,
|
||||||
|
payload: VerifyRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> TokenResponse:
|
||||||
|
token = session.exec(
|
||||||
|
select(MagicLinkToken).where(
|
||||||
|
MagicLinkToken.token_hash == hash_token(payload.token.strip())
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if (
|
||||||
|
token is None
|
||||||
|
or token.used_at is not None
|
||||||
|
or token.expires_at < local_now()
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"code": "auth.invalid_token"},
|
||||||
|
)
|
||||||
|
account = session.get(Account, token.account_id)
|
||||||
|
if account is None or account.edition_id != edition_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"code": "auth.invalid_token"},
|
||||||
|
)
|
||||||
|
token.used_at = local_now()
|
||||||
|
session.commit()
|
||||||
|
session.refresh(account)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=create_access_token(account),
|
||||||
|
token_type="bearer",
|
||||||
|
account=AccountRead.model_validate(account),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/me", response_model=AccountRead)
|
||||||
|
def read_me(account: Account = Depends(get_current_account)) -> Account:
|
||||||
|
return account
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Endpoints d'import : analyse puis exécution."""
|
"""Endpoints d'import : analyse puis exécution."""
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.api.deps import get_edition
|
||||||
|
from app.auth.deps import require_coordinator
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.importing.parser import ImportParseError, parse_upload
|
from app.importing.parser import ImportParseError, parse_upload
|
||||||
from app.importing.schemas import (
|
from app.importing.schemas import (
|
||||||
@@ -17,7 +18,7 @@ from app.importing.schemas import (
|
|||||||
PreviewRow,
|
PreviewRow,
|
||||||
)
|
)
|
||||||
from app.importing.service import run_import, suggest_mapping, validate_mapping
|
from app.importing.service import run_import, suggest_mapping, validate_mapping
|
||||||
from app.models import DayPart, Edition
|
from app.models import Account, DayPart, Edition
|
||||||
|
|
||||||
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
|
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
|
||||||
|
|
||||||
@@ -25,13 +26,6 @@ _PREVIEW_ROWS = 5
|
|||||||
_MAX_BYTES = 5 * 1024 * 1024
|
_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def _get_edition(edition_id: uuid.UUID, session: Session) -> Edition:
|
|
||||||
edition = session.get(Edition, edition_id)
|
|
||||||
if edition is None:
|
|
||||||
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
|
||||||
return edition
|
|
||||||
|
|
||||||
|
|
||||||
async def _read_upload(file: UploadFile) -> bytes:
|
async def _read_upload(file: UploadFile) -> bytes:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
if not content:
|
if not content:
|
||||||
@@ -68,11 +62,11 @@ def _day_parts(session: Session, edition: Edition) -> list[DayPart]:
|
|||||||
|
|
||||||
@router.post("/analyze", response_model=AnalyzeResponse)
|
@router.post("/analyze", response_model=AnalyzeResponse)
|
||||||
async def analyze(
|
async def analyze(
|
||||||
edition_id: uuid.UUID,
|
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> AnalyzeResponse:
|
) -> AnalyzeResponse:
|
||||||
edition = _get_edition(edition_id, session)
|
|
||||||
content = await _read_upload(file)
|
content = await _read_upload(file)
|
||||||
try:
|
try:
|
||||||
table = parse_upload(file.filename or "", content)
|
table = parse_upload(file.filename or "", content)
|
||||||
@@ -109,12 +103,12 @@ async def analyze(
|
|||||||
|
|
||||||
@router.post("/execute", response_model=ImportReport)
|
@router.post("/execute", response_model=ImportReport)
|
||||||
async def execute(
|
async def execute(
|
||||||
edition_id: uuid.UUID,
|
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
mapping: str = Form(...),
|
mapping: str = Form(...),
|
||||||
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> ImportReport:
|
) -> ImportReport:
|
||||||
edition = _get_edition(edition_id, session)
|
|
||||||
content = await _read_upload(file)
|
content = await _read_upload(file)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -6,8 +6,16 @@ from sqlmodel import Session, select
|
|||||||
|
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import MatrixPosition, MatrixResponse, MatrixShift
|
from app.api.schemas import MatrixPosition, MatrixResponse, MatrixShift
|
||||||
|
from app.auth.deps import require_staff
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models import Assignment, AssignmentStatus, Edition, Position, Shift
|
from app.models import (
|
||||||
|
Account,
|
||||||
|
Assignment,
|
||||||
|
AssignmentStatus,
|
||||||
|
Edition,
|
||||||
|
Position,
|
||||||
|
Shift,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/editions/{edition_id}/matrix", tags=["matrix"])
|
router = APIRouter(prefix="/editions/{edition_id}/matrix", tags=["matrix"])
|
||||||
|
|
||||||
@@ -15,6 +23,7 @@ router = APIRouter(prefix="/editions/{edition_id}/matrix", tags=["matrix"])
|
|||||||
@router.get("", response_model=MatrixResponse)
|
@router.get("", response_model=MatrixResponse)
|
||||||
def get_matrix(
|
def get_matrix(
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_staff),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> MatrixResponse:
|
) -> MatrixResponse:
|
||||||
positions = list(
|
positions = list(
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ from sqlmodel import Session, select
|
|||||||
from app.api._common import owned_or_404
|
from app.api._common import owned_or_404
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import PositionCreate, PositionRead, PositionUpdate
|
from app.api.schemas import PositionCreate, PositionRead, PositionUpdate
|
||||||
|
from app.auth.deps import (
|
||||||
|
current_account_in_edition,
|
||||||
|
ensure_can_write_position,
|
||||||
|
require_coordinator,
|
||||||
|
require_staff,
|
||||||
|
)
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models import Edition, Position
|
from app.models import Account, Edition, Position
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/editions/{edition_id}/positions", tags=["positions"]
|
prefix="/editions/{edition_id}/positions", tags=["positions"]
|
||||||
@@ -20,6 +26,7 @@ router = APIRouter(
|
|||||||
def create_position(
|
def create_position(
|
||||||
payload: PositionCreate,
|
payload: PositionCreate,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Position:
|
) -> Position:
|
||||||
position = Position(edition_id=edition.id, **payload.model_dump())
|
position = Position(edition_id=edition.id, **payload.model_dump())
|
||||||
@@ -32,6 +39,7 @@ def create_position(
|
|||||||
@router.get("", response_model=list[PositionRead])
|
@router.get("", response_model=list[PositionRead])
|
||||||
def list_positions(
|
def list_positions(
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_staff),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[Position]:
|
) -> list[Position]:
|
||||||
return list(
|
return list(
|
||||||
@@ -47,6 +55,7 @@ def list_positions(
|
|||||||
def get_position(
|
def get_position(
|
||||||
position_id: uuid.UUID,
|
position_id: uuid.UUID,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_staff),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Position:
|
) -> Position:
|
||||||
return owned_or_404(session.get(Position, position_id), edition.id)
|
return owned_or_404(session.get(Position, position_id), edition.id)
|
||||||
@@ -57,9 +66,11 @@ def update_position(
|
|||||||
position_id: uuid.UUID,
|
position_id: uuid.UUID,
|
||||||
payload: PositionUpdate,
|
payload: PositionUpdate,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Position:
|
) -> Position:
|
||||||
position = owned_or_404(session.get(Position, position_id), edition.id)
|
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():
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
setattr(position, field, value)
|
setattr(position, field, value)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -71,6 +82,7 @@ def update_position(
|
|||||||
def delete_position(
|
def delete_position(
|
||||||
position_id: uuid.UUID,
|
position_id: uuid.UUID,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
position = owned_or_404(session.get(Position, position_id), edition.id)
|
position = owned_or_404(session.get(Position, position_id), edition.id)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.models import (
|
|||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
AssignmentStatus,
|
AssignmentStatus,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
|
Role,
|
||||||
TshirtSize,
|
TshirtSize,
|
||||||
)
|
)
|
||||||
from app.validation import Violation
|
from app.validation import Violation
|
||||||
@@ -190,3 +191,36 @@ class MatrixPosition(ApiModel):
|
|||||||
class MatrixResponse(ApiModel):
|
class MatrixResponse(ApiModel):
|
||||||
edition_id: uuid.UUID
|
edition_id: uuid.UUID
|
||||||
positions: list[MatrixPosition]
|
positions: list[MatrixPosition]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Authentification et comptes #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class MagicLinkRequest(ApiModel):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyRequest(ApiModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class AccountRead(ApiModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
edition_id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
role: Role
|
||||||
|
volunteer_id: uuid.UUID | None
|
||||||
|
created_at: dt.datetime
|
||||||
|
updated_at: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(ApiModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
account: AccountRead
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCreate(ApiModel):
|
||||||
|
email: str
|
||||||
|
role: Role
|
||||||
|
volunteer_id: uuid.UUID | None = None
|
||||||
|
|||||||
@@ -8,8 +8,21 @@ from sqlmodel import Session, select
|
|||||||
from app.api._common import owned_or_404, replace_links, resolve_skills
|
from app.api._common import owned_or_404, replace_links, resolve_skills
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import ShiftCreate, ShiftRead, ShiftUpdate
|
from app.api.schemas import ShiftCreate, ShiftRead, ShiftUpdate
|
||||||
|
from app.auth.deps import (
|
||||||
|
current_account_in_edition,
|
||||||
|
ensure_can_write_position,
|
||||||
|
ensure_can_write_shift,
|
||||||
|
require_staff,
|
||||||
|
)
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models import Edition, Position, Shift, ShiftRequiredSkill, Skill
|
from app.models import (
|
||||||
|
Account,
|
||||||
|
Edition,
|
||||||
|
Position,
|
||||||
|
Shift,
|
||||||
|
ShiftRequiredSkill,
|
||||||
|
Skill,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/editions/{edition_id}/shifts", tags=["shifts"])
|
router = APIRouter(prefix="/editions/{edition_id}/shifts", tags=["shifts"])
|
||||||
|
|
||||||
@@ -65,10 +78,12 @@ def _require_position(
|
|||||||
def create_shift(
|
def create_shift(
|
||||||
payload: ShiftCreate,
|
payload: ShiftCreate,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> ShiftRead:
|
) -> ShiftRead:
|
||||||
_check_range(payload.starts_at, payload.ends_at)
|
_check_range(payload.starts_at, payload.ends_at)
|
||||||
_require_position(session, payload.position_id, edition.id)
|
position = _require_position(session, payload.position_id, edition.id)
|
||||||
|
ensure_can_write_position(account, position)
|
||||||
shift = Shift(
|
shift = Shift(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
**payload.model_dump(exclude={"required_skill_codes"}),
|
**payload.model_dump(exclude={"required_skill_codes"}),
|
||||||
@@ -85,6 +100,7 @@ def create_shift(
|
|||||||
def list_shifts(
|
def list_shifts(
|
||||||
position_id: uuid.UUID | None = None,
|
position_id: uuid.UUID | None = None,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_staff),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[ShiftRead]:
|
) -> list[ShiftRead]:
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -101,6 +117,7 @@ def list_shifts(
|
|||||||
def get_shift(
|
def get_shift(
|
||||||
shift_id: uuid.UUID,
|
shift_id: uuid.UUID,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_staff),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> ShiftRead:
|
) -> ShiftRead:
|
||||||
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
|
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
|
||||||
@@ -112,13 +129,16 @@ def update_shift(
|
|||||||
shift_id: uuid.UUID,
|
shift_id: uuid.UUID,
|
||||||
payload: ShiftUpdate,
|
payload: ShiftUpdate,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> ShiftRead:
|
) -> ShiftRead:
|
||||||
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
|
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
|
||||||
|
ensure_can_write_shift(account, session, shift)
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
skill_codes = changes.pop("required_skill_codes", None)
|
skill_codes = changes.pop("required_skill_codes", None)
|
||||||
if "position_id" in changes:
|
if "position_id" in changes:
|
||||||
_require_position(session, changes["position_id"], edition.id)
|
target = _require_position(session, changes["position_id"], edition.id)
|
||||||
|
ensure_can_write_position(account, target)
|
||||||
for field, value in changes.items():
|
for field, value in changes.items():
|
||||||
setattr(shift, field, value)
|
setattr(shift, field, value)
|
||||||
_check_range(shift.starts_at, shift.ends_at)
|
_check_range(shift.starts_at, shift.ends_at)
|
||||||
@@ -133,9 +153,11 @@ def update_shift(
|
|||||||
def delete_shift(
|
def delete_shift(
|
||||||
shift_id: uuid.UUID,
|
shift_id: uuid.UUID,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
|
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
|
||||||
|
ensure_can_write_shift(account, session, shift)
|
||||||
session.delete(shift)
|
session.delete(shift)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -9,14 +9,27 @@ from sqlmodel import Session, select
|
|||||||
from app.api._common import owned_or_404, replace_links, resolve_skills
|
from app.api._common import owned_or_404, replace_links, resolve_skills
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
|
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.db import get_session
|
||||||
from app.models import Edition, Skill, Volunteer, VolunteerSkill
|
from app.models import Account, Edition, Role, Skill, Volunteer, VolunteerSkill
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/editions/{edition_id}/volunteers", tags=["volunteers"]
|
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]:
|
def _skill_codes(session: Session, volunteer_id: uuid.UUID) -> list[str]:
|
||||||
return sorted(
|
return sorted(
|
||||||
session.exec(
|
session.exec(
|
||||||
@@ -53,6 +66,7 @@ def _set_skills(
|
|||||||
def create_volunteer(
|
def create_volunteer(
|
||||||
payload: VolunteerCreate,
|
payload: VolunteerCreate,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> VolunteerRead:
|
) -> VolunteerRead:
|
||||||
volunteer = Volunteer(
|
volunteer = Volunteer(
|
||||||
@@ -76,6 +90,7 @@ def create_volunteer(
|
|||||||
@router.get("", response_model=list[VolunteerRead])
|
@router.get("", response_model=list[VolunteerRead])
|
||||||
def list_volunteers(
|
def list_volunteers(
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_staff),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[VolunteerRead]:
|
) -> list[VolunteerRead]:
|
||||||
volunteers = session.exec(
|
volunteers = session.exec(
|
||||||
@@ -90,8 +105,10 @@ def list_volunteers(
|
|||||||
def get_volunteer(
|
def get_volunteer(
|
||||||
volunteer_id: uuid.UUID,
|
volunteer_id: uuid.UUID,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> VolunteerRead:
|
) -> VolunteerRead:
|
||||||
|
_ensure_can_read(account, volunteer_id)
|
||||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||||
return _read(session, volunteer)
|
return _read(session, volunteer)
|
||||||
|
|
||||||
@@ -101,6 +118,7 @@ def update_volunteer(
|
|||||||
volunteer_id: uuid.UUID,
|
volunteer_id: uuid.UUID,
|
||||||
payload: VolunteerUpdate,
|
payload: VolunteerUpdate,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> VolunteerRead:
|
) -> VolunteerRead:
|
||||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||||
@@ -126,6 +144,7 @@ def update_volunteer(
|
|||||||
def delete_volunteer(
|
def delete_volunteer(
|
||||||
volunteer_id: uuid.UUID,
|
volunteer_id: uuid.UUID,
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Authentification : lien magique (email stubé) + session JWT + rôles.
|
||||||
|
|
||||||
|
Voir CLAUDE.md § 2 (« Rôles et accès »).
|
||||||
|
"""
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Dépendances d'authentification et d'autorisation."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.auth.tokens import decode_access_token
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models import Account, Position, Role, Shift
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _unauthorized() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"code": "error.unauthorized"},
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _forbidden() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"code": "error.forbidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_account(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> Account:
|
||||||
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||||
|
raise _unauthorized()
|
||||||
|
try:
|
||||||
|
claims = decode_access_token(credentials.credentials)
|
||||||
|
account_id = uuid.UUID(claims["sub"])
|
||||||
|
except (jwt.PyJWTError, KeyError, ValueError) as exc:
|
||||||
|
raise _unauthorized() from exc
|
||||||
|
account = session.get(Account, account_id)
|
||||||
|
if account is None:
|
||||||
|
raise _unauthorized()
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def current_account_in_edition(
|
||||||
|
edition_id: uuid.UUID,
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
) -> Account:
|
||||||
|
"""Le compte doit appartenir à l'édition de l'URL."""
|
||||||
|
if account.edition_id != edition_id:
|
||||||
|
raise _forbidden()
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*roles: Role) -> Callable[..., Account]:
|
||||||
|
def dependency(
|
||||||
|
account: Account = Depends(current_account_in_edition),
|
||||||
|
) -> Account:
|
||||||
|
if account.role not in roles:
|
||||||
|
raise _forbidden()
|
||||||
|
return account
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
# Singletons prêts à l'emploi (évite un appel de fonction dans un défaut d'arg).
|
||||||
|
require_coordinator = require_roles(Role.COORDINATOR)
|
||||||
|
require_staff = require_roles(Role.COORDINATOR, Role.LEAD)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_can_write_position(account: Account, position: Position) -> None:
|
||||||
|
"""COORDINATION partout ; RÉFÉRENT uniquement sur ses propres postes."""
|
||||||
|
if account.role is Role.COORDINATOR:
|
||||||
|
return
|
||||||
|
if account.role is Role.LEAD and position.lead_account_id == account.id:
|
||||||
|
return
|
||||||
|
raise _forbidden()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_can_write_shift(
|
||||||
|
account: Account, session: Session, shift: Shift
|
||||||
|
) -> None:
|
||||||
|
if account.role is Role.COORDINATOR:
|
||||||
|
return
|
||||||
|
position = session.get(Position, shift.position_id)
|
||||||
|
if position is None:
|
||||||
|
raise _forbidden()
|
||||||
|
ensure_can_write_position(account, position)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Jetons : lien magique (aléatoire, haché en base) et session JWT."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Account
|
||||||
|
|
||||||
|
_ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
def new_raw_token() -> str:
|
||||||
|
"""Jeton de lien magique en clair (transmis à l'utilisateur une seule fois)."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(raw: str) -> str:
|
||||||
|
"""Empreinte stockée en base. Le jeton a 256 bits d'entropie : un SHA-256
|
||||||
|
simple suffit (pas de sel ni de KDF nécessaires)."""
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(account: Account) -> str:
|
||||||
|
settings = get_settings()
|
||||||
|
now = dt.datetime.now(dt.UTC)
|
||||||
|
payload = {
|
||||||
|
"sub": str(account.id),
|
||||||
|
"edi": str(account.edition_id),
|
||||||
|
"role": account.role.value,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + dt.timedelta(hours=settings.session_ttl_hours),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict:
|
||||||
|
"""Lève `jwt.PyJWTError` si le jeton est invalide ou expiré."""
|
||||||
|
return jwt.decode(
|
||||||
|
token, get_settings().jwt_secret, algorithms=[_ALGORITHM]
|
||||||
|
)
|
||||||
@@ -19,9 +19,10 @@ class Settings(BaseSettings):
|
|||||||
"postgresql+psycopg://benevoles:benevoles@localhost:5432/benevoles_test"
|
"postgresql+psycopg://benevoles:benevoles@localhost:5432/benevoles_test"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Utilisés par l'étape authentification (lien magique). Pas encore actifs.
|
# Authentification (lien magique + session JWT). Secret >= 32 octets.
|
||||||
jwt_secret: str = "dev-only-change-me"
|
jwt_secret: str = "dev-insecure-secret-change-me-please-32b"
|
||||||
magic_link_ttl_minutes: int = 30
|
magic_link_ttl_minutes: int = 30
|
||||||
|
session_ttl_hours: int = 12
|
||||||
|
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
cors_origins: list[str] = ["http://localhost:5173"]
|
cors_origins: list[str] = ["http://localhost:5173"]
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà dans l'édition.",
|
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà dans l'édition.",
|
||||||
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.",
|
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.",
|
||||||
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
|
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
|
||||||
|
"auth.invalid_token": "Lien de connexion invalide ou expiré.",
|
||||||
|
"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.",
|
||||||
|
|
||||||
"import.error.empty_file": "Le fichier est vide.",
|
"import.error.empty_file": "Le fichier est vide.",
|
||||||
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.accounts import router as account_router
|
||||||
from app.api.assignments import router as assignment_router
|
from app.api.assignments import router as assignment_router
|
||||||
|
from app.api.auth import router as auth_router
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.imports import router as import_router
|
from app.api.imports import router as import_router
|
||||||
from app.api.matrix import router as matrix_router
|
from app.api.matrix import router as matrix_router
|
||||||
@@ -26,6 +28,8 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(account_router)
|
||||||
app.include_router(import_router)
|
app.include_router(import_router)
|
||||||
app.include_router(volunteer_router)
|
app.include_router(volunteer_router)
|
||||||
app.include_router(position_router)
|
app.include_router(position_router)
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ _STAFF: dict[PositionCategory, tuple[int, int]] = {
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Summary:
|
class Summary:
|
||||||
volunteers: int = 0
|
volunteers: int = 0
|
||||||
|
accounts: int = 0
|
||||||
positions: int = 0
|
positions: int = 0
|
||||||
shifts: int = 0
|
shifts: int = 0
|
||||||
understaffed_shifts: int = 0
|
understaffed_shifts: int = 0
|
||||||
@@ -218,6 +219,7 @@ class Summary:
|
|||||||
lines = [
|
lines = [
|
||||||
"Jeu de démonstration « Festival des Lucioles » 2026",
|
"Jeu de démonstration « Festival des Lucioles » 2026",
|
||||||
f" Bénévoles : {self.volunteers}",
|
f" Bénévoles : {self.volunteers}",
|
||||||
|
f" Comptes (connexion) : {self.accounts}",
|
||||||
f" Postes : {self.positions}",
|
f" Postes : {self.positions}",
|
||||||
f" Créneaux : {self.shifts}",
|
f" Créneaux : {self.shifts}",
|
||||||
f" Créneaux sous l'effectif minimal (trous) : {self.understaffed_shifts}",
|
f" Créneaux sous l'effectif minimal (trous) : {self.understaffed_shifts}",
|
||||||
@@ -351,6 +353,15 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
session.add(volunteer)
|
session.add(volunteer)
|
||||||
session.flush()
|
session.flush()
|
||||||
volunteers.append(volunteer)
|
volunteers.append(volunteer)
|
||||||
|
if email is not None:
|
||||||
|
session.add(
|
||||||
|
Account(
|
||||||
|
edition_id=edition.id,
|
||||||
|
email=email,
|
||||||
|
role=Role.VOLUNTEER,
|
||||||
|
volunteer_id=volunteer.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
count = rng.choices([0, 1, 2, 3], weights=[0.3, 0.34, 0.24, 0.12])[0]
|
count = rng.choices([0, 1, 2, 3], weights=[0.3, 0.34, 0.24, 0.12])[0]
|
||||||
codes = set(rng.sample(list(skills), k=count))
|
codes = set(rng.sample(list(skills), k=count))
|
||||||
# Le permis B est courant : on en garantit chez ~40 % des bénévoles
|
# Le permis B est courant : on en garantit chez ~40 % des bénévoles
|
||||||
@@ -497,8 +508,16 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
for shift in volunteer_shifts:
|
for shift in volunteer_shifts:
|
||||||
fill[shift.id] += 1
|
fill[shift.id] += 1
|
||||||
|
|
||||||
|
session.flush()
|
||||||
|
account_count = len(
|
||||||
|
session.exec(
|
||||||
|
select(Account).where(Account.edition_id == edition.id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
summary = Summary(
|
summary = Summary(
|
||||||
volunteers=len(volunteers),
|
volunteers=len(volunteers),
|
||||||
|
accounts=account_count,
|
||||||
positions=len(positions),
|
positions=len(positions),
|
||||||
shifts=len(shifts),
|
shifts=len(shifts),
|
||||||
assignments=n_assignments,
|
assignments=n_assignments,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.6",
|
"pydantic-settings>=2.6",
|
||||||
"python-multipart>=0.0.12",
|
"python-multipart>=0.0.12",
|
||||||
"openpyxl>=3.1",
|
"openpyxl>=3.1",
|
||||||
|
"pyjwt>=2.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import itertools
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlmodel import Session, SQLModel, create_engine, select
|
from sqlmodel import Session, SQLModel, create_engine, select
|
||||||
|
|
||||||
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
|
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
|
||||||
|
from app.auth.deps import get_current_account
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import DayPart, Edition
|
from app.models import Account, DayPart, Edition, Role
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -36,14 +38,6 @@ def session(engine):
|
|||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(session):
|
|
||||||
app.dependency_overrides[get_session] = lambda: session
|
|
||||||
with TestClient(app) as c:
|
|
||||||
yield c
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def edition(session) -> Edition:
|
def edition(session) -> Edition:
|
||||||
"""Édition de 4 jours avec 3 demi-journées paramétrées."""
|
"""Édition de 4 jours avec 3 demi-journées paramétrées."""
|
||||||
@@ -83,3 +77,60 @@ def day_parts(session, edition) -> dict[str, DayPart]:
|
|||||||
select(DayPart).where(DayPart.edition_id == edition.id)
|
select(DayPart).where(DayPart.edition_id == edition.id)
|
||||||
)
|
)
|
||||||
return {p.code: p for p in parts}
|
return {p.code: p for p in parts}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_account(session, edition):
|
||||||
|
"""Fabrique de comptes rattachés à l'édition de test."""
|
||||||
|
counter = itertools.count()
|
||||||
|
|
||||||
|
def _make(
|
||||||
|
role: Role, *, volunteer_id=None, email: str | None = None
|
||||||
|
) -> Account:
|
||||||
|
account = Account(
|
||||||
|
edition_id=edition.id,
|
||||||
|
role=role,
|
||||||
|
email=email or f"{role.value.lower()}{next(counter)}@test",
|
||||||
|
volunteer_id=volunteer_id,
|
||||||
|
)
|
||||||
|
session.add(account)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(account)
|
||||||
|
return account
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def coordinator(make_account) -> Account:
|
||||||
|
return make_account(Role.COORDINATOR)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def as_account(session):
|
||||||
|
"""Fabrique de clients HTTP authentifiés. Fixe l'identité **active** de
|
||||||
|
l'app : le dernier appel l'emporte. Pour rejouer un autre rôle après en
|
||||||
|
avoir simulé un, rappeler `as_account(...)`."""
|
||||||
|
|
||||||
|
def _make(account: Account) -> TestClient:
|
||||||
|
app.dependency_overrides[get_session] = lambda: session
|
||||||
|
app.dependency_overrides[get_current_account] = lambda: account
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
yield _make
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(coordinator, as_account) -> TestClient:
|
||||||
|
"""Client authentifié en COORDINATION (rôle par défaut des tests)."""
|
||||||
|
return as_account(coordinator)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def raw_client(session):
|
||||||
|
"""Client sans authentification simulée (pour tester le lien magique)."""
|
||||||
|
app.dependency_overrides[get_session] = lambda: session
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.auth.tokens import hash_token
|
||||||
|
from app.models import MagicLinkToken, Role, local_now
|
||||||
|
|
||||||
|
|
||||||
|
def _url(edition, suffix):
|
||||||
|
return f"/editions/{edition.id}/auth/{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_magic_link_unknown_email_is_accepted_without_token(
|
||||||
|
raw_client, session, edition
|
||||||
|
):
|
||||||
|
resp = raw_client.post(
|
||||||
|
_url(edition, "magic-link"), json={"email": "personne@example.fr"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 202
|
||||||
|
assert session.exec(select(MagicLinkToken)).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_flow_magic_link_then_jwt_then_me(
|
||||||
|
raw_client, session, edition, make_account, caplog
|
||||||
|
):
|
||||||
|
make_account(Role.COORDINATOR, email="coord@example.fr")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="app.auth"):
|
||||||
|
resp = raw_client.post(
|
||||||
|
_url(edition, "magic-link"), json={"email": "COORD@example.fr"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 202
|
||||||
|
log_line = next(m for m in caplog.messages if "STUB lien magique" in m)
|
||||||
|
raw_token = log_line.split("jeton=")[-1].strip()
|
||||||
|
|
||||||
|
verified = raw_client.post(
|
||||||
|
_url(edition, "verify"), json={"token": raw_token}
|
||||||
|
)
|
||||||
|
assert verified.status_code == 200
|
||||||
|
body = verified.json()
|
||||||
|
assert body["token_type"] == "bearer"
|
||||||
|
assert body["account"]["role"] == "COORDINATOR"
|
||||||
|
|
||||||
|
me = raw_client.get(
|
||||||
|
"/auth/me",
|
||||||
|
headers={"Authorization": f"Bearer {body['access_token']}"},
|
||||||
|
)
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.json()["email"] == "coord@example.fr"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_is_single_use(raw_client, session, edition, make_account):
|
||||||
|
account = make_account(Role.VOLUNTEER, email="v@example.fr")
|
||||||
|
session.add(
|
||||||
|
MagicLinkToken(
|
||||||
|
account_id=account.id,
|
||||||
|
token_hash=hash_token("jeton-connu"),
|
||||||
|
expires_at=local_now() + dt.timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
first = raw_client.post(_url(edition, "verify"), json={"token": "jeton-connu"})
|
||||||
|
assert first.status_code == 200
|
||||||
|
second = raw_client.post(
|
||||||
|
_url(edition, "verify"), json={"token": "jeton-connu"}
|
||||||
|
)
|
||||||
|
assert second.status_code == 401
|
||||||
|
assert second.json()["detail"]["code"] == "auth.invalid_token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_expired_token(raw_client, session, edition, make_account):
|
||||||
|
account = make_account(Role.VOLUNTEER, email="v@example.fr")
|
||||||
|
session.add(
|
||||||
|
MagicLinkToken(
|
||||||
|
account_id=account.id,
|
||||||
|
token_hash=hash_token("perime"),
|
||||||
|
expires_at=local_now() - dt.timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
resp = raw_client.post(_url(edition, "verify"), json={"token": "perime"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_unknown_token(raw_client, edition):
|
||||||
|
resp = raw_client.post(_url(edition, "verify"), json={"token": "n'importe"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_requires_a_valid_bearer(raw_client):
|
||||||
|
assert raw_client.get("/auth/me").status_code == 401
|
||||||
|
assert (
|
||||||
|
raw_client.get(
|
||||||
|
"/auth/me", headers={"Authorization": "Bearer pas-un-jwt"}
|
||||||
|
).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""Application des rôles : COORDINATION / RÉFÉRENT (LEAD) / BÉNÉVOLE."""
|
||||||
|
|
||||||
|
from app.models import Assignment, Role
|
||||||
|
from tests.factories import (
|
||||||
|
dt_at,
|
||||||
|
make_position,
|
||||||
|
make_shift,
|
||||||
|
make_volunteer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _e(edition, suffix=""):
|
||||||
|
return f"/editions/{edition.id}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_endpoints_require_authentication(raw_client, edition):
|
||||||
|
assert raw_client.get(_e(edition, "/volunteers")).status_code == 401
|
||||||
|
assert raw_client.get(_e(edition, "/matrix")).status_code == 401
|
||||||
|
assert raw_client.post(_e(edition, "/positions"), json={}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_from_another_edition_is_forbidden(
|
||||||
|
session, as_account, edition, make_account
|
||||||
|
):
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.models import Edition
|
||||||
|
|
||||||
|
other = Edition(
|
||||||
|
year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4)
|
||||||
|
)
|
||||||
|
session.add(other)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(other)
|
||||||
|
from app.models import Account
|
||||||
|
|
||||||
|
stranger = Account(
|
||||||
|
edition_id=other.id, email="x@e.fr", role=Role.COORDINATOR
|
||||||
|
)
|
||||||
|
session.add(stranger)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(stranger)
|
||||||
|
|
||||||
|
client = as_account(stranger)
|
||||||
|
assert client.get(_e(edition, "/volunteers")).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# BÉNÉVOLE #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_volunteer_scope(session, edition, make_account, as_account):
|
||||||
|
me = make_volunteer(session, edition, email="me@e.fr")
|
||||||
|
other = make_volunteer(session, edition, email="other@e.fr")
|
||||||
|
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
|
||||||
|
client = as_account(account)
|
||||||
|
|
||||||
|
assert client.get(_e(edition, "/volunteers")).status_code == 403
|
||||||
|
assert client.get(_e(edition, f"/volunteers/{me.id}")).status_code == 200
|
||||||
|
assert client.get(_e(edition, f"/volunteers/{other.id}")).status_code == 403
|
||||||
|
assert client.get(_e(edition, "/matrix")).status_code == 403
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
_e(edition, "/volunteers"),
|
||||||
|
json={"first_name": "A", "last_name": "B"},
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_volunteer_sees_only_own_assignments(
|
||||||
|
session, edition, make_account, as_account
|
||||||
|
):
|
||||||
|
me = make_volunteer(session, edition, email="me@e.fr")
|
||||||
|
other = make_volunteer(session, edition, email="other@e.fr")
|
||||||
|
position = make_position(session, edition)
|
||||||
|
shift = make_shift(
|
||||||
|
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
Assignment(
|
||||||
|
edition_id=edition.id, volunteer_id=me.id, shift_id=shift.id
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
|
||||||
|
client = as_account(account)
|
||||||
|
|
||||||
|
mine = client.get(_e(edition, "/assignments"))
|
||||||
|
assert mine.status_code == 200
|
||||||
|
assert all(a["volunteer_id"] == str(me.id) for a in mine.json())
|
||||||
|
|
||||||
|
assert (
|
||||||
|
client.get(
|
||||||
|
_e(edition, "/assignments"),
|
||||||
|
params={"volunteer_id": str(other.id)},
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# RÉFÉRENT (LEAD) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_lead_write_is_limited_to_own_positions(
|
||||||
|
session, edition, make_account, as_account
|
||||||
|
):
|
||||||
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
|
mine = make_position(session, edition, name="Bar")
|
||||||
|
mine.lead_account_id = lead.id
|
||||||
|
session.add(mine)
|
||||||
|
session.commit()
|
||||||
|
theirs = make_position(session, edition, name="Régie")
|
||||||
|
|
||||||
|
client = as_account(lead)
|
||||||
|
|
||||||
|
# Lecture sur toute l'édition.
|
||||||
|
assert client.get(_e(edition, "/matrix")).status_code == 200
|
||||||
|
assert client.get(_e(edition, "/positions")).status_code == 200
|
||||||
|
|
||||||
|
# Création de poste réservée à la coordination.
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
_e(edition, "/positions"),
|
||||||
|
json={"name": "N", "category": "BAR"},
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
# PATCH : son poste oui, celui d'un autre non.
|
||||||
|
assert (
|
||||||
|
client.patch(
|
||||||
|
_e(edition, f"/positions/{mine.id}"), json={"location": "Chapiteau"}
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.patch(
|
||||||
|
_e(edition, f"/positions/{theirs.id}"), json={"location": "X"}
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
# Créneau : sur son poste oui, sur celui d'un autre non.
|
||||||
|
ok = client.post(
|
||||||
|
_e(edition, "/shifts"),
|
||||||
|
json={
|
||||||
|
"position_id": str(mine.id),
|
||||||
|
"starts_at": "2026-07-10T09:00:00",
|
||||||
|
"ends_at": "2026-07-10T12:00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert ok.status_code == 201
|
||||||
|
ko = client.post(
|
||||||
|
_e(edition, "/shifts"),
|
||||||
|
json={
|
||||||
|
"position_id": str(theirs.id),
|
||||||
|
"starts_at": "2026-07-10T09:00:00",
|
||||||
|
"ends_at": "2026-07-10T12:00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert ko.status_code == 403
|
||||||
|
|
||||||
|
# Ni bénévoles ni import.
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
_e(edition, "/volunteers"),
|
||||||
|
json={"first_name": "A", "last_name": "B"},
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
_e(edition, "/import/analyze"),
|
||||||
|
files={"file": ("x.csv", b"a;b\n1;2\n", "text/csv")},
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_assignment_scope(session, edition, make_account, as_account):
|
||||||
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
|
mine = make_position(session, edition, name="Bar")
|
||||||
|
mine.lead_account_id = lead.id
|
||||||
|
session.add(mine)
|
||||||
|
session.commit()
|
||||||
|
theirs = make_position(session, edition, name="Accueil")
|
||||||
|
|
||||||
|
my_shift = make_shift(
|
||||||
|
session, edition, mine, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||||
|
)
|
||||||
|
their_shift = make_shift(
|
||||||
|
session, edition, theirs, start=dt_at(11, 9), end=dt_at(11, 12)
|
||||||
|
)
|
||||||
|
volunteer = make_volunteer(session, edition, email="v@e.fr")
|
||||||
|
client = as_account(lead)
|
||||||
|
|
||||||
|
ok = client.post(
|
||||||
|
_e(edition, "/assignments"),
|
||||||
|
json={
|
||||||
|
"volunteer_id": str(volunteer.id),
|
||||||
|
"shift_id": str(my_shift.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert ok.status_code == 201
|
||||||
|
ko = client.post(
|
||||||
|
_e(edition, "/assignments"),
|
||||||
|
json={
|
||||||
|
"volunteer_id": str(volunteer.id),
|
||||||
|
"shift_id": str(their_shift.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert ko.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Comptes (coordination uniquement) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_account_management_is_coordinator_only(
|
||||||
|
session, edition, coordinator, make_account, as_account
|
||||||
|
):
|
||||||
|
coord_client = as_account(coordinator)
|
||||||
|
|
||||||
|
created = coord_client.post(
|
||||||
|
_e(edition, "/accounts"),
|
||||||
|
json={
|
||||||
|
"email": "nouveau.referent@e.fr",
|
||||||
|
"role": "LEAD",
|
||||||
|
"volunteer_id": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
account_id = created.json()["id"]
|
||||||
|
assert coord_client.get(_e(edition, "/accounts")).status_code == 200
|
||||||
|
|
||||||
|
lead_client = as_account(make_account(Role.LEAD, email="lead@e.fr"))
|
||||||
|
assert lead_client.get(_e(edition, "/accounts")).status_code == 403
|
||||||
|
assert (
|
||||||
|
lead_client.delete(
|
||||||
|
_e(edition, f"/accounts/{account_id}")
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
# `as_account` fixe l'identité active : on repasse en COORDINATION.
|
||||||
|
coord_client = as_account(coordinator)
|
||||||
|
assert (
|
||||||
|
coord_client.delete(
|
||||||
|
_e(edition, f"/accounts/{account_id}")
|
||||||
|
).status_code
|
||||||
|
== 204
|
||||||
|
)
|
||||||
Generated
+11
@@ -56,6 +56,7 @@ dependencies = [
|
|||||||
{ name = "openpyxl" },
|
{ name = "openpyxl" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "pyjwt" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "sqlmodel" },
|
{ name = "sqlmodel" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
@@ -76,6 +77,7 @@ requires-dist = [
|
|||||||
{ name = "openpyxl", specifier = ">=3.1" },
|
{ name = "openpyxl", specifier = ">=3.1" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.6" },
|
{ name = "pydantic-settings", specifier = ">=2.6" },
|
||||||
|
{ name = "pyjwt", specifier = ">=2.9" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.12" },
|
{ name = "python-multipart", specifier = ">=0.0.12" },
|
||||||
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
|
||||||
@@ -422,6 +424,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147 },
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyjwt"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.1.1"
|
version = "9.1.1"
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ services:
|
|||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/${POSTGRES_DB:-benevoles}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/${POSTGRES_DB:-benevoles}
|
||||||
TEST_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/benevoles_test
|
TEST_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-benevoles}:${POSTGRES_PASSWORD:-benevoles}@db:5432/benevoles_test
|
||||||
ENVIRONMENT: development
|
ENVIRONMENT: development
|
||||||
JWT_SECRET: ${JWT_SECRET:-dev-only-change-me}
|
JWT_SECRET: ${JWT_SECRET:-dev-insecure-secret-change-me-please-32b}
|
||||||
TZ: Europe/Paris
|
TZ: Europe/Paris
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|||||||
Reference in New Issue
Block a user