Compare commits

..
Author SHA1 Message Date
Johan LEROY 3ad19ed089 test(backend): généralise la vérification du 403 de rôle à toute route, pas seulement admin
Backend / Lint, typage et tests (push) Successful in 1m6s
test_every_administration_route_documents_the_role_refusal ne couvrait que le tag users : sites
vient de passer inaperçu deux fois (tag et 403 absents) pendant le merge avec dev. Remplacé par
ROUTES_A_ROLE, une liste maintenue à la main sur le modèle de ORIGINE_VERIFIEE, qui couvre toute
route derrière require_role. Ajoute la checklist d'ajout d'une route métier à 20-backend.md.
2026-09-16 12:09:53 +02:00
269 changed files with 364 additions and 28847 deletions
-27
View File
@@ -1,27 +0,0 @@
# Dépendances (réinstallées dans l'image)
node_modules/
vendor/
__pycache__/
*.pyc
# Git et IDE
.git/
.gitignore
.vscode/
.idea/
*.swp
# Fichiers de build locaux
dist/
build/
*.log
# Secrets et config locale (CRITIQUE : risque d'exfiltration)
.env
.env.local
*.pem
*.key
secrets/
.npmrc
.pypirc
kubeconfig
-36
View File
@@ -17,39 +17,3 @@ APP_LOG_LEVEL=INFO
APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200
BACKEND_PORT=8000
FRONTEND_PORT=3000
# Mailpit capture les courriels du backend, rien ne sort vers l'extérieur.
MAILPIT_SMTP_PORT=1025
MAILPIT_UI_PORT=8025
# API Mock EnerVision
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10
# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus.
AIRFLOW_PORT=8080
# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre :
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
AIRFLOW_FERNET_KEY=change_me
# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_WEBSERVER_SECRET_KEY=change_me
AIRFLOW_ADMIN_USERNAME=admin
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
# comptes `app_user` d'EnerVision.
AIRFLOW_ADMIN_PASSWORD=change_me
AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
# `APP_SECRET_KEY` du backend, que le DAG `alertes` lance en sous-processus. Distincte de
# celle de l'API : la détection ne signe aucun jeton, et Airflow exécute du code depuis son
# interface (cf. ADR 0008). Générer la vôtre :
# python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_APP_SECRET_KEY=change_me
# Stack complète derrière le reverse proxy (docker-compose.prod.yml).
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
PUBLIC_HOST=enervision.local
ACME_EMAIL=
-46
View File
@@ -1,46 +0,0 @@
version: 2
updates:
# Frontend — npm
- package-ecosystem: "npm"
directory: "/apps/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
frontend-dependencies:
patterns:
- "*"
# Backend — uv (lit pyproject.toml / uv.lock)
- package-ecosystem: "uv"
directory: "/apps/backend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
backend-dependencies:
patterns:
- "*"
# Les workflows GitHub Actions eux-mêmes ont aussi des dépendances à jour
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Si un Dockerfile existe pour le backend
- package-ecosystem: "docker"
directory: "/apps/backend"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/apps/frontend"
schedule:
interval: "weekly"
# Images du reverse proxy et du compagnon ACME, épinglées dans les fichiers Compose
- package-ecosystem: "docker-compose"
directory: "/"
schedule:
interval: "weekly"
-102
View File
@@ -1,102 +0,0 @@
name: Airflow
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
#
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans
# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API.
on:
push:
paths:
- "etl/airflow/**"
- "ml/pyproject.toml"
- "ml/uv.lock"
- "ml/enervision_ml/**"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- "apps/backend/app/**"
- ".github/workflows/airflow.yml"
pull_request:
paths:
- "etl/airflow/**"
- "ml/pyproject.toml"
- "ml/uv.lock"
- "ml/enervision_ml/**"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
- "apps/backend/app/**"
- ".github/workflows/airflow.yml"
permissions:
contents: read
concurrency:
group: airflow-${{ github.ref }}
cancel-in-progress: true
jobs:
verification:
name: Lint et intégrité des DAGs
runs-on: ubuntu-latest
defaults:
run:
working-directory: etl/airflow
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: etl/airflow/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
- name: Vérifie le formatage
run: uv run ruff format --check .
- name: Analyse statique
run: uv run ruff check --output-format=github .
# Aucun test ne lance de tâche ni de scheduler : DagBag charge les fichiers de dags/ et
# vérifie import, planification, plafonds d'exécution et commande de chaque tâche.
- name: Tests d'intégrité des DAGs
run: uv run pytest
image:
name: Construction de l'image
runs-on: ubuntu-latest
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
# Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent
# (`OSError: libgomp.so.1` au premier import) ou environnement ml/ non figé.
- name: Vérifie que le pipeline ML s'importe sans réseau
run: >
docker run --rm --network none enervision-airflow:ci
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
# l'import des modules prouve que l'environnement /opt/backend est complet.
# Les deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes.
- name: Vérifie que les trois commandes backend s'importent sans réseau
run: >
docker run --rm --network none enervision-airflow:ci
bash -c "cd /opt/backend
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help"
-110
View File
@@ -56,113 +56,3 @@ jobs:
# Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici.
- name: Tests et couverture
run: uv run pytest --cov-fail-under=85
# Piège : l'image est celle de docker-compose.yml, pas une image `postgres` nue. La première
# migration (`5353c0e4f094`) échoue volontairement si l'extension TimescaleDB manque, et un
# écart d'image entre la CI et le poste rendrait ce job vert sur une base qui n'est pas la nôtre.
integration:
name: Tests exigeant une base
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
services:
db:
image: timescale/timescaledb-ha:pg17
env:
POSTGRES_USER: enervision
POSTGRES_PASSWORD: change_me
POSTGRES_DB: enervision_test
ports:
- "5433:5432"
options: >-
--health-cmd "pg_isready -U enervision -d enervision_test"
--health-interval 10s
--health-timeout 5s
--health-retries 12
--health-start-period 40s
env:
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test
APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur
PGPASSWORD: change_me
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
# Sur le poste, c'est db/init/110-test-database.sql qui pose l'extension. Ce fichier n'est
# pas monté ici, et sans lui `alembic upgrade head` s'arrête sur la garde de la révision 1.
- name: Active TimescaleDB sur la base de test
run: psql -h localhost -p 5433 -U enervision -d enervision_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
- name: Applique les migrations
run: uv run alembic upgrade head
# `-m` en ligne de commande écrase celui d'`addopts`. La couverture est désactivée : ce job
# ne joue qu'une partie de la suite, son taux n'aurait aucun sens face au seuil de 85 %.
- name: Tests d'intégration
run: uv run pytest -m integration --no-cov
security-audit:
name: Audit des dépendances
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
# L'audit porte sur le verrou, pas sur l'environnement : sinon pip-audit auditerait
# aussi les paquets que son propre `--with` injecte, hors dépendances du projet.
- name: Audite les dépendances livrées
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
shell: bash
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
sast:
name: Analyse statique de sécurité
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/backend
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
uses: astral-sh/setup-uv@v5
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
- name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium
# Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace.
- name: Rapport complet, tous niveaux
continue-on-error: true
run: uvx bandit==1.9.4 --recursive app
-64
View File
@@ -1,64 +0,0 @@
name: Frontend
on:
push:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
pull_request:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
security-audit:
name: Audit des dépendances
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front)
run: npm ci
working-directory: apps/frontend
- name : Lancement des tests et génénration du rapport de couverture (Front)
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
-82
View File
@@ -1,82 +0,0 @@
name: ML
# Piège : la version de Python vient de ml/.python-version, et doit rester en 3.14 (cf.
# .github/workflows/backend.yml, même contrainte).
on:
push:
paths:
- "ml/**"
- ".github/workflows/ml.yml"
pull_request:
paths:
- "ml/**"
- ".github/workflows/ml.yml"
permissions:
contents: read
concurrency:
group: ml-${{ github.ref }}
cancel-in-progress: true
jobs:
verification:
name: Lint, typage et tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: ml
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
- name: Vérifie le formatage
run: uv run ruff format --check .
- name: Analyse statique
run: uv run ruff check --output-format=github .
- name: Typage
run: uv run mypy enervision_ml tests
# Aucun test ne touche PostgreSQL ni MLflow distant : tout tourne sur donnees
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
- name: Tests
run: uv run pytest
sast:
name: Analyse statique de sécurité
runs-on: ubuntu-latest
defaults:
run:
working-directory: ml
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
uses: astral-sh/setup-uv@v5
- name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
- name: Rapport complet, tous niveaux
continue-on-error: true
run: uvx bandit==1.9.4 --recursive enervision_ml
-169
View File
@@ -1,169 +0,0 @@
name: SonarQube
on:
push:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml"
pull_request:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml"
# Build l'ensemble du projet, puis lance les tests
# Génère les rapports de couverture, puis lance l'analyse SonarQube
jobs:
build-front:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
test-front:
needs: build-front
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front)
run: npm ci
working-directory: apps/frontend
- name : Lancement des tests et génénration du rapport de couverture (Front)
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
build-back:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: apps/backend
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
working-directory: apps/backend
- name: Vérifie le formatage
run: uv run ruff format --check .
working-directory: apps/backend
- name: Analyse statique
run: uv run ruff check --output-format=github .
working-directory: apps/backend
- name: Typage
run: uv run mypy app
working-directory: apps/backend
test-back:
needs: build-back
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
- name : Lancement des tests et génénration du rapport de couverture (Back)
run: uv run pytest --cov-fail-under=85 --cov-report=xml
working-directory: apps/backend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: apps/backend/coverage.xml
test-ml:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: ml
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
working-directory: ml
- name: Lancement des tests et génération du rapport de couverture (ML)
run: uv run pytest --cov-report=xml
working-directory: ml
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: ml-coverage
path: ml/coverage.xml
sonarqube:
needs: [build-front, build-back, test-front, test-back, test-ml]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v4
with:
name: backend-coverage
path: apps/backend
- name: Téléchargement du rapport de couverture (ML)
uses: actions/download-artifact@v4
with:
name: ml-coverage
path: ml
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+1 -16
View File
@@ -52,26 +52,11 @@ standalone_admin_password.txt
secrets/
# Donnees locales
data/raw/*
!data/raw/.gitkeep
data/
*.sqlite3
monitoring/grafana/data/
monitoring/prometheus/data/
# ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux
ml/data/
ml/models/*
!ml/models/.gitkeep
ml/mlruns/
ml/mlartifacts/
ml/mlflow.db
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
etl/airflow/tests/.airflow_home/
# TLS : certificats du reverse proxy, générés par script ou par certbot
infra/proxy/tls/*.pem
# IDE et OS
.idea/
.vscode/
+4 -114
View File
@@ -1,58 +1,18 @@
BACKEND := apps/backend
FRONTEND := apps/frontend
ML := ml
AIRFLOW := etl/airflow
COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml
# Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose.
# PUBLIC_HOST retombe sur le `.env`, que make ne lit pas, puis sur la valeur de `.env.example`.
PUBLIC_HOST ?= $(shell sed -n 's/^PUBLIC_HOST=//p' .env 2>/dev/null | tail -1)
PUBLIC_HOST := $(or $(strip $(PUBLIC_HOST)),enervision.local)
export PUBLIC_HOST
ifdef ACME_EMAIL
export ACME_EMAIL
endif
.DEFAULT_GOAL := help
.PHONY: help install install-backend install-frontend install-ml install-airflow \
dev dev-backend dev-frontend \
lint format typecheck test test-cov test-integration check \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
.PHONY: help install dev lint format typecheck test test-cov test-integration check \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin
help: ## Liste les cibles disponibles
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow
install-backend: ## Installe les dépendances du backend
install: ## Installe les dépendances du backend
cd $(BACKEND) && uv sync --all-groups
install-frontend: ## Installe les dépendances du frontend
cd $(FRONTEND) && npm ci
install-ml: ## Installe les dépendances du pipeline ML
cd $(ML) && uv sync --all-groups
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
cd $(AIRFLOW) && uv sync --all-groups
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
@trap 'kill 0' EXIT INT TERM; \
$(MAKE) --no-print-directory dev-backend & \
$(MAKE) --no-print-directory dev-frontend & \
wait
dev-backend: ## Lance l'API seule en rechargement à chaud
@echo "backend -> http://localhost:8000 (docs sur /docs)"
dev: ## Lance l'API en rechargement à chaud
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
dev-frontend: ## Lance le frontend seul en rechargement à chaud
@echo "frontend -> http://localhost:4200"
cd $(FRONTEND) && npm start
lint: ## Analyse statique du backend
cd $(BACKEND) && uv run ruff check .
@@ -77,79 +37,9 @@ check: lint typecheck test ## Chaîne de vérification complète
openapi: ## Régénère apps/backend/openapi.json depuis les routes déclarées
cd $(BACKEND) && uv run python -m app.cli export-openapi
ml-lint: ## Analyse statique du pipeline ML
cd $(ML) && uv run ruff check .
ml-typecheck: ## Vérifie le typage du pipeline ML
cd $(ML) && uv run mypy enervision_ml tests
ml-test: ## Exécute les tests du pipeline ML (donnees synthetiques, sans base ni serveur MLflow)
cd $(ML) && uv run pytest
ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel
cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),)
airflow-lint: ## Analyse statique des DAGs Airflow
cd $(AIRFLOW) && uv run ruff check .
airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structure attendue
cd $(AIRFLOW) && uv run pytest
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
docker compose up -d airflow-init airflow-webserver airflow-scheduler
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
airflow-down: ## Arrête le webserver et le scheduler Airflow
docker compose stop airflow-webserver airflow-scheduler
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
docker compose logs -f airflow-scheduler
docker-build: ## Construit l'image du backend
docker build -t enervision-backend:local $(BACKEND)
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin
@test -f infra/proxy/tls/fullchain.pem \
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
$(COMPOSE_PROD) up -d --build
stack-down: ## Arrête la stack complète en conservant les données
$(COMPOSE_PROD) stop
stack-logs: ## Suit les journaux du reverse proxy
$(COMPOSE_PROD) logs -f proxy
tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST public et ACME_EMAIL requis
@test "$(PUBLIC_HOST)" != enervision.local \
|| { echo "PUBLIC_HOST doit être un domaine public résolvable, pas le nom de démonstration"; exit 1; }
$(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \
-d $(PUBLIC_HOST) \
--email $${ACME_EMAIL:?ACME_EMAIL=... requis} \
--agree-tos --no-eff-email --deploy-hook /deploy-hook.sh
$(COMPOSE_PROD) exec proxy nginx -s reload
tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy
$(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh
$(COMPOSE_PROD) exec proxy nginx -s reload
db-up: ## Démarre la base PostgreSQL TimescaleDB
docker compose up -d db
+11 -34
View File
@@ -21,12 +21,10 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs |
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
@@ -48,16 +46,13 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ ├── migrations/ Migrations SQL versionnees
│ └── seeds/ Jeux de donnees de reference
├── etl/airflow/
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique)
│ ├── dags/ DAGs d'ingestion et d'agregation
│ ├── plugins/ Operateurs et hooks maison
│ ├── include/ Requetes SQL et ressources des DAGs
│ └── tests/ Tests d'integrite des DAGs
├── infra/
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
│ └── terraform/
│ ├── modules/ Modules reutilisables
│ └── environments/ Racines Terraform, une par environnement
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
├── infra/terraform/
│ ├── modules/ Modules reutilisables
│ └── environments/ Racines Terraform, une par environnement
├── monitoring/
│ ├── prometheus/ Collecte et regles d'alerte
│ ├── grafana/ Provisioning et dashboards
@@ -68,17 +63,16 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
## Demarrage
Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Python 3.14, que
`uv` installe seul.
Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul.
```bash
cp .env.example .env # variables de docker-compose
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
make install # dependances du backend et du frontend
make install # dependances du backend
make migrate # applique les migrations Alembic
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
make dev # API sur http://localhost:8000, docs sur /docs
make check # lint + typage + tests
```
@@ -89,11 +83,9 @@ Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker
5432, souvent deja pris par une autre base.
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend`
et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend`
du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc
toute modification y demande un `docker compose up -d --build backend`.
conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
et n'embarque pas le source, donc toute modification y demande un
`docker compose up -d --build backend`.
Verifier que la base repond et que l'extension est chargee :
@@ -101,21 +93,6 @@ Verifier que la base repond et que l'extension est chargee :
curl -s localhost:8000/api/v1/health/ready
```
## Stack complète derrière le reverse proxy
Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine.
L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus récent** :
```bash
make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration
make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, rien d'autre n'est publié
```
Le navigateur avertit d'un émetteur inconnu : Let's Encrypt reste hors d'atteinte tant qu'aucun
nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvellement dans
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
## Conventions
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
-14
View File
@@ -8,17 +8,3 @@ APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
# Mot de passe oublié : lien à usage unique valable 15 minutes par défaut.
APP_FRONTEND_RESET_PASSWORD_URL=http://localhost:4200/reset-password
# SMTP local de dev (Mailpit, cf. docker-compose.yml) : aucune authentification, aucun TLS.
# À remplacer par un vrai relais en staging/prod.
APP_SMTP_HOST=localhost
APP_SMTP_PORT=1025
APP_SMTP_USE_TLS=false
APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10
+4 -5
View File
@@ -11,14 +11,13 @@ WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev --no-build
uv sync --locked --no-install-project --no-dev
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
FROM python:3.14-slim AS runtime
-5
View File
@@ -103,17 +103,12 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
| `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente |
| `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton |
| `/api/v1/auth/password` | Change son propre mot de passe | jeton |
| `/api/v1/auth/forgot-password` | Demande un lien de réinitialisation par email | public |
| `/api/v1/auth/reset-password` | Choisit un nouveau mot de passe depuis ce lien | public |
| `/api/v1/auth/me` | Décrit le compte connecté | jeton |
| `/api/v1/users` | Liste et crée des comptes | `admin` |
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` |
| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` |
| `/api/v1/sites` | Liste les sites | `lecteur` |
| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` |
| `/api/v1/recommendations` | Liste les recommandations | `lecteur` |
| `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `lecteur` |
| `/api/v1/recommendations/generate` | Génère les recommandations depuis les alertes (POST) | `admin` |
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
+3 -21
View File
@@ -123,11 +123,6 @@ async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> Non
defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre
declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus.
Ces tests ne sont pas pour autant facultatifs : le job `integration` de
`.github/workflows/backend.yml` monte un service TimescaleDB, applique les migrations et
les joue a chaque poussee. Un test `integration` casse donc la CI comme un autre. En local,
`make db-up` puis `make test-integration`.
## Couverture
Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique
@@ -147,27 +142,14 @@ uv run pytest tests/api/test_health.py # un seul fichier
uv run pytest -k readiness # par motif de nom
```
## Quatre fichiers à connaître avant de toucher à l'authentification
`tests/api/acces.py` porte la classification des routes du contrat, en quatre ensembles :
`ROUTES_PUBLIQUES`, `ROUTE_COOKIE`, `ROUTES_SANS_ROLE` et la table `ROLE_MINIMUM`. Ce n'est pas
un fichier de test, c'est la référence que les trois autres confrontent au comportement observé.
**Toute route ajoutée doit y être classée** : `test_every_declared_route_is_classified` échoue
sinon, et échoue aussi sur une entrée qui ne correspond plus à aucune route.
## Trois fichiers à connaître avant de toucher à l'authentification
`tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de
dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route
peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose
donc de modifier `ROUTES_PUBLIQUES` dans `acces.py`**, ce qui apparaît en clair dans la diff
d'une pull request.
`tests/api/test_matrice_acces.py` croise chaque route gardée avec chacun des trois rôles, dans
les deux sens : un rôle insuffisant reçoit un 403 `Droits insuffisants`, un rôle suffisant ne le
reçoit jamais. Le second sens est ce qui rend visible une garde posée trop haut, par exemple
`AdminDep` sur une route de lecture. La même matrice est rejouée sous `integration` avec de vrais
jetons, donc en traversant le décodage du JWT et la relecture du compte en base, que
`dependency_overrides` court-circuite.
donc de modifier la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la
diff d'une pull request.
`tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend
possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test
@@ -1,96 +0,0 @@
"""jetons et tentatives de reinitialisation de mot de passe
Revision ID: c0adab96238c
Revises: e6d2026091501
Create Date: 2026-09-17 10:37:12.571314
Meme schema que `refresh_token` pour `password_reset_token` : seule l'empreinte SHA-256 du
jeton est stockee, jamais le jeton lui-meme, pour la meme raison (revocation en cascade,
aucune session utilisable dans un pg_dump qui fuiterait).
`password_reset_attempt` vit hors de `audit_log`, comme `login_attempt`, car son volume est
pilote par l'attaquant : une campagne de demandes y ecrirait des lignes que l'audit, en ajout
seul, ne devrait jamais purger.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "c0adab96238c"
down_revision: str | Sequence[str] | None = "e6d2026091501"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
JETONS_VIVANTS = "consumed_at is null"
def upgrade() -> None:
op.create_table(
"password_reset_attempt",
sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("email_tried", sa.String(length=320), nullable=False),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_password_reset_attempt"),
)
op.create_index(
"ix_password_reset_attempt_email_date",
"password_reset_attempt",
["email_tried", "occurred_at"],
)
op.create_index(
"ix_password_reset_attempt_ip_date", "password_reset_attempt", ["client_ip", "occurred_at"]
)
op.create_table(
"password_reset_token",
sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("token_hash", sa.LargeBinary(), nullable=False),
sa.Column(
"issued_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["app_user.id"],
name="fk_password_reset_token_user",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name="pk_password_reset_token"),
sa.UniqueConstraint("token_hash", name="uq_password_reset_token_hash"),
)
op.create_index("ix_password_reset_token_user", "password_reset_token", ["user_id"])
op.create_index(
"ix_password_reset_token_vivants",
"password_reset_token",
["user_id"],
postgresql_where=JETONS_VIVANTS,
)
def downgrade() -> None:
op.drop_index(
"ix_password_reset_token_vivants",
table_name="password_reset_token",
postgresql_where=JETONS_VIVANTS,
)
op.drop_index("ix_password_reset_token_user", table_name="password_reset_token")
op.drop_table("password_reset_token")
op.drop_index("ix_password_reset_attempt_ip_date", table_name="password_reset_attempt")
op.drop_index("ix_password_reset_attempt_email_date", table_name="password_reset_attempt")
op.drop_table("password_reset_attempt")
+2 -94
View File
@@ -16,31 +16,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings, get_settings
from app.core.hashing import Argon2Hasher, build_hasher
from app.core.mailer import Mailer, SmtpConfig
from app.core.principal import Principal
from app.core.roles import AccountKind, Role, has_at_least
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
from app.core.security import decode_access_token as decode_token
from app.db.session import get_session
from app.repositories.alert import AlertRepository
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository
from app.repositories.prediction import PredictionRepository
from app.repositories.reading import ReadingRepository
from app.repositories.recommendation import RecommendationRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository
from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
from app.services.prediction import PredictionService
from app.services.reading import ReadingService
from app.services.recommendation import RecommendationService
from app.services.sensor import SensorService
from app.services.auth import AuthService, LoginPolicy
from app.services.site import SiteService
from app.services.stats import StatsService
from app.services.user import UserService
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -103,27 +90,11 @@ def get_client_ip(request: Request, settings: SettingsDep) -> str | None:
return request.client.host if request.client else None
def get_mailer(settings: SettingsDep) -> Mailer:
return Mailer(
SmtpConfig(
host=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=(
settings.smtp_password.get_secret_value() if settings.smtp_password else None
),
use_tls=settings.smtp_use_tls,
from_address=settings.smtp_from_address,
)
)
def get_auth_service(
session: SessionDep,
settings: SettingsDep,
hasher: Annotated[Argon2Hasher, Depends(get_hasher)],
token_policy: Annotated[TokenPolicy, Depends(get_token_policy)],
mailer: Annotated[Mailer, Depends(get_mailer)],
) -> AuthService:
return AuthService(
users=UserRepository(session),
@@ -140,16 +111,6 @@ def get_auth_service(
max_failures_per_identifier=settings.login_max_failures_per_identifier,
),
refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds),
reset_tokens=PasswordResetTokenRepository(session),
reset_attempts=PasswordResetAttemptRepository(session),
reset_policy=PasswordResetPolicy(
window_seconds=settings.password_reset_window_seconds,
max_requests_per_identifier=settings.password_reset_max_requests_per_identifier,
max_requests_per_ip=settings.password_reset_max_requests_per_ip,
token_ttl=timedelta(seconds=settings.password_reset_ttl_seconds),
frontend_reset_url=settings.frontend_reset_password_url,
),
mailer=mailer,
)
@@ -173,65 +134,12 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)]
def get_site_service(session: SessionDep) -> SiteService:
return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session))
return SiteService(sites=SiteRepository(session))
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
def get_alert_service(session: SessionDep) -> AlertService:
return AlertService(
alerts=AlertRepository(session),
readings=ReadingRepository(session),
predictions=PredictionRepository(session),
sites=SiteRepository(session),
)
AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)]
def get_recommendation_service(session: SessionDep) -> RecommendationService:
return RecommendationService(
recommendations=RecommendationRepository(session),
alerts=AlertRepository(session),
transaction=session,
)
RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)]
def get_stats_service(session: SessionDep) -> StatsService:
return StatsService(sites=SiteRepository(session), readings=ReadingRepository(session))
StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)]
def get_reading_service(session: SessionDep) -> ReadingService:
return ReadingService(readings=ReadingRepository(session))
ReadingServiceDep = Annotated[ReadingService, Depends(get_reading_service)]
def get_sensor_service(session: SessionDep) -> SensorService:
return SensorService(sites=SiteRepository(session), readings=ReadingRepository(session))
SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)]
def get_prediction_service(session: SessionDep) -> PredictionService:
return PredictionService(
sites=SiteRepository(session), predictions=PredictionRepository(session)
)
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
async def get_current_principal(
credentials: CredentialsDep,
session: SessionDep,
-49
View File
@@ -54,42 +54,6 @@ TAGS: Final[list[dict[str, Any]]] = [
"name": "sites",
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
},
{
"name": "alerts",
"description": "Consultation des alertes de consommation. Accessible à partir du rôle "
"`lecteur`.",
},
{
"name": "recommendations",
"description": (
"Consultation des recommandations issues des alertes. Accessible à partir du rôle "
"`lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`."
),
},
{
"name": "stats",
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle "
"`lecteur`.",
},
{
"name": "readings",
"description": (
"Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, "
"24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du "
"rôle `lecteur`."
),
},
{
"name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`.",
},
{
"name": "predictions",
"description": (
"Dernière prévision de consommation par site, calculée hors ligne par le pipeline "
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
),
},
]
cookie_de_rafraichissement = APIKeyCookie(
@@ -171,16 +135,3 @@ REPONSE_ORIGINE_REFUSEE: Final[Reponses] = {
"description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).",
},
}
REPONSE_LIMITE: Final[Reponses] = {
429: {
"model": ErrorResponse,
"description": "Trop de demandes sur cette fenêtre glissante.",
"headers": {
"Retry-After": {
"description": "Secondes à attendre avant une nouvelle tentative.",
"schema": {"type": "integer"},
}
},
},
}
@@ -1,23 +0,0 @@
from fastapi import APIRouter
from app.api.deps import AlertServiceDep, LecteurDep
from app.api.openapi import REPONSE_VALIDATION
from app.schemas.alert import AlertResponse, AlertSeverity
router = APIRouter()
@router.get(
"",
response_model=list[AlertResponse],
summary="Liste les alertes",
responses=REPONSE_VALIDATION,
)
async def list_alerts(
_: LecteurDep,
service: AlertServiceDep,
site_id: str | None = None,
severity: AlertSeverity | None = None,
) -> list[AlertResponse]:
alertes = await service.list_all(site_id=site_id, severity=severity)
return [AlertResponse.model_validate(alerte) for alerte in alertes]
+1 -97
View File
@@ -2,7 +2,7 @@
# d'accès ne va jamais dans un cookie. C'est ce qui réduit la surface CSRF aux trois routes de
# ce module : partout ailleurs, le navigateur n'attache rien de lui-même.
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from app.api.deps import (
AuthServiceDep,
@@ -12,7 +12,6 @@ from app.api.deps import (
require_trusted_origin,
)
from app.api.openapi import (
REPONSE_LIMITE,
REPONSE_ORIGINE_REFUSEE,
REPONSE_VALIDATION,
REPONSES_AUTHENTIFIEES,
@@ -22,19 +21,15 @@ from app.api.openapi import (
from app.core.cookies import RefreshCookie, cookie_name
from app.core.logging import get_logger
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
PasswordChangeRequest,
PrincipalResponse,
ResetPasswordRequest,
ResetTokenValidationResponse,
TokenResponse,
)
from app.schemas.errors import ErrorResponse
from app.services.auth import (
AuthenticatedSession,
InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
RateLimitedError,
SessionRejectedError,
)
@@ -44,7 +39,6 @@ logger = get_logger(__name__)
DETAIL_IDENTIFIANTS = "Identifiants invalides"
DETAIL_SESSION = "Session invalide"
DETAIL_LIEN_RESET = "Lien invalide ou expiré"
REPONSES_LOGIN: Reponses = {
**REPONSE_VALIDATION,
@@ -91,20 +85,6 @@ REPONSES_MOT_DE_PASSE: Reponses = {
},
}
REPONSES_FORGOT_PASSWORD: Reponses = {
**REPONSE_VALIDATION,
**REPONSE_LIMITE,
}
REPONSES_RESET_PASSWORD: Reponses = {
**REPONSE_VALIDATION,
**REPONSE_ORIGINE_REFUSEE,
400: {
"model": ErrorResponse,
"description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).",
},
}
def repond(
response: Response, settings: SettingsDep, session: AuthenticatedSession
@@ -287,79 +267,3 @@ async def change_password(
logger.info("auth.password_changed user_id=%s", principal.id)
return repond(response, settings, session)
@router.post(
"/forgot-password",
status_code=status.HTTP_202_ACCEPTED,
summary="Demande un lien de réinitialisation par email",
responses=REPONSES_FORGOT_PASSWORD,
)
async def forgot_password(
payload: ForgotPasswordRequest,
request: Request,
response: Response,
service: AuthServiceDep,
background_tasks: BackgroundTasks,
client_ip: str | None = Depends(get_client_ip),
) -> None:
response.headers["Cache-Control"] = "no-store"
try:
await service.request_password_reset(
email=payload.email,
client_ip=client_ip,
user_agent=request.headers.get("user-agent"),
background_tasks=background_tasks,
)
except RateLimitedError as erreur:
logger.warning("auth.password_reset.rate_limited ip=%s", client_ip)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Trop de demandes, réessayez plus tard",
headers={"Retry-After": str(erreur.retry_after)},
) from erreur
@router.get(
"/reset-password/validate",
response_model=ResetTokenValidationResponse,
summary="Vérifie sans le consommer si un lien de réinitialisation est encore valide",
responses=REPONSE_VALIDATION,
)
async def validate_reset_token(token: str, service: AuthServiceDep) -> ResetTokenValidationResponse:
return ResetTokenValidationResponse(valid=await service.is_reset_token_valid(token=token))
@router.post(
"/reset-password",
response_model=TokenResponse,
summary="Choisit un nouveau mot de passe depuis un lien reçu par email",
dependencies=[Depends(require_trusted_origin)],
responses=REPONSES_RESET_PASSWORD,
)
async def reset_password(
payload: ResetPasswordRequest,
request: Request,
response: Response,
settings: SettingsDep,
service: AuthServiceDep,
client_ip: str | None = Depends(get_client_ip),
) -> TokenResponse:
response.headers["Cache-Control"] = "no-store"
try:
session = await service.confirm_password_reset(
token=payload.token,
new_password=payload.new_password,
client_ip=client_ip,
user_agent=request.headers.get("user-agent"),
)
except InvalidOrExpiredResetTokenError as erreur:
logger.warning("auth.password_reset.invalid_token ip=%s", client_ip)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=DETAIL_LIEN_RESET
) from erreur
logger.info("auth.password_reset.success user_id=%s", session.principal.id)
return repond(response, settings, session)
@@ -1,18 +0,0 @@
from fastapi import APIRouter
from app.api.deps import LecteurDep, PredictionServiceDep
from app.schemas.prediction import PredictionSummaryResponse
router = APIRouter()
@router.get(
"",
response_model=PredictionSummaryResponse,
summary="Dernière prédiction de consommation par site",
)
async def get_predictions(
_: LecteurDep, service: PredictionServiceDep
) -> PredictionSummaryResponse:
resume = await service.summary()
return PredictionSummaryResponse.model_validate(resume)
@@ -1,54 +0,0 @@
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import LecteurDep, ReadingServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.reading import ReadingResponse
from app.services.reading import FenetreInverseeError, FenetreTropLargeError
router = APIRouter()
REPONSES_FENETRE: Reponses = {
**REPONSE_VALIDATION,
400: {
"model": ErrorResponse,
"description": (
"Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre "
"les deux supérieur à 90 jours."
),
},
}
@router.get(
"",
response_model=list[ReadingResponse],
summary="Liste l'historique des lectures",
responses=REPONSES_FENETRE,
)
async def list_readings(
_: LecteurDep,
service: ReadingServiceDep,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(500, ge=1, le=2000),
offset: int = Query(0, ge=0),
) -> list[ReadingResponse]:
try:
lectures = await service.list_history(
site_id=site_id, start=start, end=end, limit=limit, offset=offset
)
except FenetreInverseeError as erreur:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="`start` doit être strictement antérieur à `end`",
) from erreur
except FenetreTropLargeError as erreur:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="L'écart entre `start` et `end` ne peut pas dépasser 90 jours",
) from erreur
return [ReadingResponse.model_validate(lecture) for lecture in lectures]
@@ -1,64 +0,0 @@
from fastapi import APIRouter, HTTPException, status
from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep
from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.recommendation import (
RecommendationGenerationResponse,
RecommendationResponse,
)
from app.services.recommendation import RecommendationNotFoundError
router = APIRouter()
REPONSES_GENERATION: Reponses = {**REPONSES_ADMIN, **REPONSE_VALIDATION}
REPONSES_INTROUVABLE: Reponses = {
**REPONSE_VALIDATION,
404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."},
}
@router.get("", response_model=list[RecommendationResponse], summary="Liste les recommandations")
async def list_recommendations(
_: LecteurDep, service: RecommendationServiceDep
) -> list[RecommendationResponse]:
recommendations = await service.list_all()
return [RecommendationResponse.model_validate(r) for r in recommendations]
@router.get(
"/{recommendation_id}",
response_model=RecommendationResponse,
summary="Décrit une recommandation",
responses=REPONSES_INTROUVABLE,
)
async def get_recommendation(
recommendation_id: int, _: LecteurDep, service: RecommendationServiceDep
) -> RecommendationResponse:
try:
recommendation = await service.get_by_id(recommendation_id)
except RecommendationNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
) from erreur
return RecommendationResponse.model_validate(recommendation)
@router.post(
"/generate",
response_model=RecommendationGenerationResponse,
summary="Génère les recommandations à partir des alertes",
responses=REPONSES_GENERATION,
)
async def generate_recommendations(
_: AdminDep,
service: RecommendationServiceDep,
site_id: str | None = None,
) -> RecommendationGenerationResponse:
rapport = await service.generate(site_id=site_id)
return RecommendationGenerationResponse(
alerts_examined=rapport.alertes_examinees,
recommendations_created=rapport.recommandations_creees,
already_present=rapport.deja_presentes,
)
@@ -1,16 +0,0 @@
from fastapi import APIRouter
from app.api.deps import AdminDep, SensorServiceDep
from app.schemas.sensor import SensorStatusResponse
router = APIRouter()
@router.get(
"/status",
response_model=SensorStatusResponse,
summary="État de santé des capteurs par site",
)
async def get_status(_: AdminDep, service: SensorServiceDep) -> SensorStatusResponse:
etat = await service.status()
return SensorStatusResponse.model_validate(etat)
+1 -17
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status
from app.api.deps import LecteurDep, SiteServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.site import SiteCurrentResponse, SiteResponse
from app.schemas.site import SiteResponse
from app.services.site import SiteNotFoundError
router = APIRouter()
@@ -34,19 +34,3 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteResponse.model_validate(site)
@router.get(
"/{site_id}/current",
response_model=SiteCurrentResponse,
summary="Dernière mesure d'un site",
responses=REPONSES_INTROUVABLE,
)
async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse:
try:
actuel = await service.current(site_id)
except SiteNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteCurrentResponse.model_validate(actuel)
@@ -1,16 +0,0 @@
from fastapi import APIRouter
from app.api.deps import LecteurDep, StatsServiceDep
from app.schemas.stats import StatsSummaryResponse
router = APIRouter()
@router.get(
"/summary",
response_model=StatsSummaryResponse,
summary="Résume la consommation instantanée du parc",
)
async def get_summary(_: LecteurDep, service: StatsServiceDep) -> StatsSummaryResponse:
resume = await service.summary()
return StatsSummaryResponse.model_validate(resume)
+1 -31
View File
@@ -1,40 +1,10 @@
from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
from app.api.v1.endpoints import (
alerts,
auth,
health,
predictions,
readings,
recommendations,
sensors,
sites,
stats,
users,
)
from app.api.v1.endpoints import auth, health, sites, users
api_router = APIRouter(responses=REPONSE_SERVEUR)
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
api_router.include_router(
alerts.router, prefix="/alerts", tags=["alerts"], responses=REPONSES_LECTEUR
)
api_router.include_router(
recommendations.router,
prefix="/recommendations",
tags=["recommendations"],
responses=REPONSES_LECTEUR,
)
api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR)
api_router.include_router(
readings.router, prefix="/readings", tags=["readings"], responses=REPONSES_LECTEUR
)
api_router.include_router(
sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN
)
api_router.include_router(
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
)
+4 -55
View File
@@ -9,7 +9,6 @@ import argparse
import asyncio
import json
import secrets
import string
import sys
from getpass import getpass
from pathlib import Path
@@ -22,13 +21,10 @@ from app.core.hashing import build_hasher
from app.core.roles import Role
from app.db.session import get_session_factory
from app.main import create_app
from app.repositories.alert import AlertRepository
from app.repositories.recommendation import RecommendationRepository
from app.repositories.user import UserRepository
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
from app.services.recommendation import RecommendationService
LONGUEUR_MOT_DE_PASSE_GENERE = 24
LONGUEUR_MINIMALE = 12
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
@@ -66,22 +62,6 @@ async def create_admin(
)
async def generate_recommendations(*, site_id: str | None) -> str:
async with get_session_factory()() as session:
service = RecommendationService(
recommendations=RecommendationRepository(session),
alerts=AlertRepository(session),
transaction=session,
)
rapport = await service.generate(site_id=site_id)
return (
f"{rapport.alertes_examinees} alerte(s) examinée(s), "
f"{rapport.recommandations_creees} recommandation(s) créée(s), "
f"{rapport.deja_presentes} déjà présente(s)"
)
# Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le
# fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle
# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris.
@@ -128,45 +108,18 @@ def build_parser() -> argparse.ArgumentParser:
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
)
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT))
recommandations = sous_commandes.add_parser(
"generate-recommendations",
help="Applique le moteur de règles aux alertes en base",
)
recommandations.add_argument(
"--site-id", default=None, help="Limite le traitement aux alertes d'un site"
)
return parser
def genere_mot_de_passe() -> str:
tirage = secrets.SystemRandom()
classes = [
string.ascii_uppercase,
string.ascii_lowercase,
string.digits,
SPECIAL_CHARACTERS,
]
reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes)
caracteres = [tirage.choice(classe) for classe in classes]
caracteres += [tirage.choice("".join(classes)) for _ in range(reste)]
tirage.shuffle(caracteres)
return "".join(caracteres)
def read_password(*, generate: bool) -> str:
if generate:
mot_de_passe = genere_mot_de_passe()
mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE)
print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}")
return mot_de_passe
mot_de_passe = getpass("Mot de passe : ")
if len(mot_de_passe) < PASSWORD_MIN_LENGTH:
raise SystemExit(f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères")
try:
valide_complexite(mot_de_passe)
except ValueError as erreur:
raise SystemExit(str(erreur)) from erreur
if len(mot_de_passe) < LONGUEUR_MINIMALE:
raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères")
if mot_de_passe != getpass("Confirmation : "):
raise SystemExit("Les deux saisies diffèrent")
return mot_de_passe
@@ -179,10 +132,6 @@ def main(argv: list[str] | None = None) -> int:
print(export_openapi(Path(arguments.output)))
return 0
if arguments.commande == "generate-recommendations":
print(asyncio.run(generate_recommendations(site_id=arguments.site_id)))
return 0
mot_de_passe = read_password(generate=arguments.generate)
succes, message = asyncio.run(
-18
View File
@@ -34,11 +34,6 @@ class Settings(BaseSettings):
database_pool_size: int = 5
database_max_overflow: int = 10
mock_api_base_url: str = "https://api-mock.charlieandre.fr"
mock_api_username: str | None = None
mock_api_password: SecretStr | None = None
mock_api_timeout_seconds: float = Field(default=10.0, gt=0)
jwt_issuer: str = "enervision-api"
jwt_audience: str = "enervision-web"
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
@@ -59,19 +54,6 @@ class Settings(BaseSettings):
login_max_failures_per_ip: int = Field(default=20, ge=1)
login_max_failures_per_identifier: int = Field(default=50, ge=1)
password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600)
password_reset_window_seconds: int = Field(default=900, ge=60)
password_reset_max_requests_per_identifier: int = Field(default=3, ge=1)
password_reset_max_requests_per_ip: int = Field(default=10, ge=1)
smtp_host: str = "localhost"
smtp_port: int = Field(default=587, ge=1, le=65535)
smtp_username: str | None = None
smtp_password: SecretStr | None = None
smtp_use_tls: bool = False
smtp_from_address: str = "no-reply@enervision.fr"
frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105
trust_proxy_headers: bool = False
expose_api_docs: bool | None = None
metrics_token: SecretStr | None = None
-48
View File
@@ -1,48 +0,0 @@
# Piège : l'URL de réinitialisation porte le jeton en clair. Ne jamais la journaliser :
# `send_password_reset_email()` ne logue que le destinataire, jamais `reset_url`.
from dataclasses import dataclass
from email.message import EmailMessage
import aiosmtplib
from app.core.logging import get_logger
logger = get_logger(__name__)
@dataclass(frozen=True, slots=True)
class SmtpConfig:
host: str
port: int
username: str | None
password: str | None
use_tls: bool
from_address: str
class Mailer:
def __init__(self, config: SmtpConfig) -> None:
self._config = config
async def send_password_reset_email(self, *, to: str, reset_url: str) -> None:
message = EmailMessage()
message["From"] = self._config.from_address
message["To"] = to
message["Subject"] = "Réinitialisation de votre mot de passe EnerVision"
message.set_content(
"Une réinitialisation de mot de passe a été demandée pour ce compte.\n\n"
f"Ouvrez ce lien dans les 15 minutes pour choisir un nouveau mot de passe : "
f"{reset_url}\n\n"
"Si vous n'êtes pas à l'origine de cette demande, ignorez cet email."
)
_, message_recu = await aiosmtplib.send(
message,
hostname=self._config.host,
port=self._config.port,
username=self._config.username,
password=self._config.password,
use_tls=self._config.use_tls,
)
logger.info("mailer.password_reset_sent to=%s smtp_response=%s", to, message_recu)
@@ -1,68 +0,0 @@
# Détection d'alertes internes EnerVision (issue #104) : script lancé à la main pour l'instant,
# comme `enervision_ml.score` côté ML, sans automatisation Airflow pour l'ordonnancer.
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime
from app.core.config import get_settings
from app.db.session import get_session_factory
from app.repositories.alert import AlertRepository
from app.repositories.prediction import PredictionRepository
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.alert import AlertService
async def run_detection(*, now: datetime | None = None, site_id: str | None = None) -> int:
"""Exécute les cinq règles de détection et enregistre les nouvelles alertes. Rend le nombre de
lignes effectivement insérées (les doublons de `source_alert_id` sont silencieusement
ignorés)."""
async with get_session_factory()() as session:
service = AlertService(
alerts=AlertRepository(session),
readings=ReadingRepository(session),
predictions=PredictionRepository(session),
sites=SiteRepository(session),
)
nouvelles = await service.detect(now=now, site_id=site_id)
await session.commit()
return len(nouvelles)
def _parse_instant(valeur: str) -> datetime:
instant = datetime.fromisoformat(valeur)
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="python -m app.detection.internal_alerts",
description="Détection d'alertes internes EnerVision",
)
parser.add_argument("--site-id", default=None, help="Limite la détection à un seul site.")
parser.add_argument(
"--now",
type=_parse_instant,
default=None,
help=(
"Instant de référence (ISO 8601, UTC si le fuseau est omis). Défaut : l'heure courante."
),
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
# Échoue tôt si `APP_SECRET_KEY`/`DATABASE_URL` manquent, avant toute requête à la base.
get_settings()
nombre = asyncio.run(run_detection(now=args.now, site_id=args.site_id))
print(f"{nombre} nouvelle(s) alerte(s) enregistrée(s).")
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
View File
-621
View File
@@ -1,621 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
from pathlib import Path
from typing import Any, cast
import pandas as pd
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings
REQUIRED_COLUMNS = {
"timestamp",
"site_id",
"site_type",
"site_name",
"consumption_kwh",
"consumption_euros",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
"hour",
"day_of_week",
"day_name",
"month",
"is_weekend",
"is_working_hours",
}
MEASURE_COLUMNS = [
"consumption_kwh",
"consumption_euros",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
]
SOURCE_NAME = "csv"
def compute_sha256(path: Path) -> str:
"""Calcule l'empreinte SHA-256 du fichier source."""
sha256 = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
sha256.update(block)
return sha256.hexdigest()
def load_metadata(path: Path) -> dict[str, Any]:
"""Charge les métadonnées fournies avec le dataset."""
with path.open("r", encoding="utf-8") as source:
metadata = json.load(source)
if not isinstance(metadata, dict):
raise ValueError("Le fichier de métadonnées doit contenir un objet JSON.")
return cast(dict[str, Any], metadata)
def classify_quality(
row: dict[str, Any],
) -> tuple[str, list[str]]:
"""
Déduit une qualité technique à partir des champs manquants.
Les valeurs NULL sont conservées. On ne cherche pas ici à
déterminer la cause physique exacte de leur absence.
"""
missing = [column for column in MEASURE_COLUMNS if pd.isna(row.get(column))]
if not missing:
quality = "good"
elif len(missing) == len(MEASURE_COLUMNS):
quality = "critical"
elif "consumption_kwh" in missing:
quality = "degraded"
else:
quality = "partial"
reasons = [f"missing:{column}" for column in missing]
return quality, reasons
def validate_source(
frame: pd.DataFrame,
metadata: dict[str, Any],
) -> None:
"""Valide le dataset avant tout chargement en base."""
missing_columns = REQUIRED_COLUMNS.difference(frame.columns)
if missing_columns:
raise ValueError(f"Colonnes obligatoires absentes : {sorted(missing_columns)}")
expected_records = int(metadata["total_records"])
if len(frame) != expected_records:
raise ValueError(f"Nombre de lignes inattendu : {len(frame)} au lieu de {expected_records}")
expected_sites = set(metadata["sites"].keys())
actual_sites = set(frame["site_id"].unique())
if actual_sites != expected_sites:
raise ValueError(
f"Sites incohérents. Attendus={sorted(expected_sites)}, trouvés={sorted(actual_sites)}"
)
duplicated = frame.duplicated(subset=["site_id", "timestamp"]).sum()
if duplicated:
raise ValueError(f"{duplicated} doublons (site_id, timestamp) détectés")
static_variants = frame.groupby("site_id")[["site_type", "site_name"]].nunique()
if (static_variants > 1).any().any():
raise ValueError("Un site possède plusieurs valeurs de site_type ou site_name.")
# Vérifie également que tous les timestamps
# peuvent être interprétés correctement.
pd.to_datetime(
frame["timestamp"],
errors="raise",
)
def normalize_timestamps(
frame: pd.DataFrame,
source_timezone: str,
) -> pd.DataFrame:
"""
Normalise les timestamps et leur associe une timezone.
Les timestamps originaux sont conservés dans une colonne
temporaire afin de pouvoir les stocker dans raw_data.
"""
normalized = frame.copy()
normalized["_source_timestamp"] = normalized["timestamp"]
timestamps = pd.to_datetime(
normalized["timestamp"],
errors="raise",
)
if timestamps.dt.tz is None:
timestamps = timestamps.dt.tz_localize(source_timezone)
else:
timestamps = timestamps.dt.tz_convert(source_timezone)
normalized["timestamp"] = timestamps
return normalized
def to_json_value(value: Any) -> Any:
"""
Convertit une valeur Pandas/Numpy en valeur
compatible JSON.
"""
if value is None:
return None
try:
if pd.isna(value):
return None
except TypeError, ValueError:
pass
if isinstance(value, pd.Timestamp):
return value.isoformat()
if hasattr(value, "item"):
return value.item()
return value
async def ensure_dataset(
connection: AsyncConnection,
metadata: dict[str, Any],
sha256: str,
source_timezone: str,
storage_uri: str,
) -> int:
"""
Crée l'entrée dataset si elle n'existe pas.
Le SHA-256 permet de reconnaître un fichier déjà importé
et participe à l'idempotence et à la traçabilité.
"""
result = await connection.execute(
text(
"""
SELECT dataset_id
FROM dataset
WHERE archive_sha256 = :sha256
LIMIT 1
"""
),
{
"sha256": sha256,
},
)
existing = result.scalar_one_or_none()
if existing is not None:
return int(existing)
metadata_summary = {
"generator_version": metadata.get("generator_version"),
"total_sites": metadata.get("total_sites"),
"total_records": metadata.get("total_records"),
"date_range": metadata.get("date_range"),
"frequency": metadata.get("frequency"),
"null_injection_enabled": metadata.get("null_injection_enabled"),
"null_strategies": metadata.get("null_strategies"),
"importer": "historical_import_v1",
}
result = await connection.execute(
text(
"""
INSERT INTO dataset (
dataset_name,
archive_sha256,
storage_uri,
source_timezone,
"metadata"
)
VALUES (
:dataset_name,
:archive_sha256,
:storage_uri,
:source_timezone,
CAST(:metadata AS jsonb)
)
RETURNING dataset_id
"""
),
{
"dataset_name": ("EnerVision historical dataset 2023-2024"),
"archive_sha256": sha256,
"storage_uri": storage_uri,
"source_timezone": source_timezone,
"metadata": json.dumps(
metadata_summary,
ensure_ascii=False,
),
},
)
return int(result.scalar_one())
async def upsert_sites(
connection: AsyncConnection,
frame: pd.DataFrame,
) -> None:
"""Insère ou met à jour les sites du dataset."""
sites = cast(
list[dict[str, Any]],
frame[
[
"site_id",
"site_type",
"site_name",
]
]
.drop_duplicates(subset=["site_id"])
.to_dict(orient="records"),
)
await connection.execute(
text(
"""
INSERT INTO site (
site_id,
site_type,
site_name
)
VALUES (
:site_id,
:site_type,
:site_name
)
ON CONFLICT (site_id)
DO UPDATE SET
site_type = EXCLUDED.site_type,
site_name = EXCLUDED.site_name
"""
),
sites,
)
def build_reading_batch(
chunk: pd.DataFrame,
dataset_id: int,
) -> list[dict[str, Any]]:
"""
Transforme un chunk Pandas en lignes prêtes
à être chargées dans la table reading.
"""
rows: list[dict[str, Any]] = []
records = cast(
list[dict[str, Any]],
chunk.to_dict(orient="records"),
)
for record in records:
quality, reasons = classify_quality(record)
raw_data = {
column: to_json_value(value)
for column, value in record.items()
if column != "_source_timestamp"
}
# Dans raw_data, on conserve le timestamp
# exactement tel qu'il était dans le CSV.
raw_data["timestamp"] = to_json_value(record["_source_timestamp"])
rows.append(
{
"site_id": record["site_id"],
"timestamp": record["timestamp"],
"source": SOURCE_NAME,
"dataset_id": dataset_id,
# Non fourni par le dataset historique.
"consumption_kw": None,
"consumption_kwh": to_json_value(record["consumption_kwh"]),
"consumption_euros": to_json_value(record["consumption_euros"]),
# Non fournis par le CSV historique.
"voltage_v": None,
"current_a": None,
"power_factor": None,
"temperature_celsius": (to_json_value(record["temperature_celsius"])),
"humidity_percent": (to_json_value(record["humidity_percent"])),
"solar_irradiance_wm2": (to_json_value(record["solar_irradiance_wm2"])),
"is_working_hours": bool(record["is_working_hours"]),
"data_quality": quality,
"null_reasons": reasons,
# Aucune imputation pendant l'ingestion RAW.
# Les valeurs manquantes sont conservées telles quelles
# afin de préserver la donnée source.
"imputed_values": None,
"imputation_method": None,
# Conservation de la donnée source
# pour la traçabilité.
"raw_data": json.dumps(
raw_data,
ensure_ascii=False,
),
}
)
return rows
READING_INSERT = text(
"""
INSERT INTO reading (
site_id,
timestamp,
source,
dataset_id,
consumption_kw,
consumption_kwh,
consumption_euros,
voltage_v,
current_a,
power_factor,
temperature_celsius,
humidity_percent,
solar_irradiance_wm2,
is_working_hours,
data_quality,
null_reasons,
imputed_values,
imputation_method,
raw_data
)
VALUES (
:site_id,
:timestamp,
:source,
:dataset_id,
:consumption_kw,
:consumption_kwh,
:consumption_euros,
:voltage_v,
:current_a,
:power_factor,
:temperature_celsius,
:humidity_percent,
:solar_irradiance_wm2,
:is_working_hours,
:data_quality,
:null_reasons,
CAST(:imputed_values AS jsonb),
:imputation_method,
CAST(:raw_data AS jsonb)
)
ON CONFLICT DO NOTHING
"""
)
async def import_historical(
csv_path: Path,
metadata_path: Path,
source_timezone: str,
batch_size: int,
dry_run: bool,
storage_uri: str,
) -> None:
"""
Exécute le pipeline ETL historique EnerVision.
Étapes :
1. Extract
2. Validate
3. Transform
4. Load
"""
metadata = load_metadata(metadata_path)
frame = pd.read_csv(csv_path)
validate_source(
frame,
metadata,
)
print(f"Lignes : {len(frame)}")
print(f"Sites : {frame['site_id'].nunique()}")
print(f"Période : {frame['timestamp'].min()} -> {frame['timestamp'].max()}")
print(f"Doublons : {frame.duplicated(['site_id', 'timestamp']).sum()}")
print("\nValeurs NULL :")
print(frame[MEASURE_COLUMNS].isna().sum())
sha256 = compute_sha256(csv_path)
print(f"\nSHA-256 : {sha256}")
if dry_run:
print("\nDry-run terminé : aucune donnée écrite.")
return
normalized = normalize_timestamps(
frame,
source_timezone,
)
settings = get_settings()
engine = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
)
try:
async with engine.begin() as connection:
dataset_id = await ensure_dataset(
connection=connection,
metadata=metadata,
sha256=sha256,
source_timezone=source_timezone,
storage_uri=storage_uri,
)
await upsert_sites(
connection,
normalized,
)
result = await connection.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE dataset_id = :dataset_id
AND source = :source
"""
),
{
"dataset_id": dataset_id,
"source": SOURCE_NAME,
},
)
before = int(result.scalar_one())
for start in range(
0,
len(normalized),
batch_size,
):
chunk = normalized.iloc[start : start + batch_size]
rows = build_reading_batch(
chunk,
dataset_id,
)
await connection.execute(
READING_INSERT,
rows,
)
loaded = min(
start + batch_size,
len(normalized),
)
print(f"Chargement : {loaded}/{len(normalized)}")
result = await connection.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE dataset_id = :dataset_id
AND source = :source
"""
),
{
"dataset_id": dataset_id,
"source": SOURCE_NAME,
},
)
after = int(result.scalar_one())
print("\nImport terminé.")
print(f"dataset_id : {dataset_id}")
print(f"lectures avant : {before}")
print(f"lectures après : {after}")
print(f"nouvelles lectures : {after - before}")
finally:
await engine.dispose()
def parse_args() -> argparse.Namespace:
"""Définit les arguments CLI de l'import."""
parser = argparse.ArgumentParser(description=("Import historique EnerVision"))
parser.add_argument(
"--csv",
type=Path,
required=True,
help="Chemin vers le CSV historique.",
)
parser.add_argument(
"--metadata",
type=Path,
required=True,
help=("Chemin vers le fichier dataset_metadata.json."),
)
parser.add_argument(
"--source-timezone",
default="UTC",
help=("Timezone associée aux timestamps du dataset. Défaut : UTC."),
)
parser.add_argument(
"--batch-size",
type=int,
default=1000,
help=("Nombre de lignes insérées par batch. Défaut : 1000."),
)
parser.add_argument(
"--dry-run",
action="store_true",
help=("Valide les données sans écrire en base."),
)
return parser.parse_args()
def main() -> None:
"""Point d'entrée CLI du pipeline."""
args = parse_args()
if args.batch_size <= 0:
raise ValueError("--batch-size doit être strictement supérieur à 0.")
# resolve() est volontairement exécuté ici,
# dans la partie synchrone du programme.
# Cela évite une opération filesystem bloquante
# à l'intérieur d'une fonction async.
storage_uri = args.csv.resolve().as_uri()
asyncio.run(
import_historical(
csv_path=args.csv,
metadata_path=args.metadata,
source_timezone=(args.source_timezone),
batch_size=args.batch_size,
dry_run=args.dry_run,
storage_uri=storage_uri,
)
)
if __name__ == "__main__":
main()
-416
View File
@@ -1,416 +0,0 @@
# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance.
# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie
# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs
# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS et la taille
# des tableaux est plafonnée par MAX_SITES et par --limit. Une valeur hors bornes devient NULL
# et laisse sa trace dans null_reasons plutôt que de lever : le mock émet des anomalies par
# construction, et raw_data conserve de toute façon la réponse d'origine intacte.
from __future__ import annotations
import argparse
import asyncio
import json
from datetime import datetime
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings
SOURCE_HISTORY = "api_history"
MAX_SITES = 100
MAX_LIMIT = 1000
# Les quatre seules valeurs que la contrainte ck_reading_quality accepte.
ACCEPTED_QUALITIES = frozenset({"good", "partial", "degraded", "critical"})
PHYSICAL_BOUNDS: dict[str, tuple[float, float]] = {
"consumption_kw": (0.0, 100_000.0),
"consumption_kwh": (0.0, 100_000.0),
"voltage_v": (0.0, 1_000.0),
"current_a": (0.0, 10_000.0),
"power_factor": (0.0, 1.0),
"temperature_celsius": (-90.0, 60.0),
"humidity_percent": (0.0, 100.0),
}
CAPACITY_BOUNDS = (0.0, 100_000.0)
def create_mock_api_client() -> httpx.AsyncClient:
settings = get_settings()
if settings.mock_api_username is None or settings.mock_api_password is None:
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
return httpx.AsyncClient(
base_url=settings.mock_api_base_url.rstrip("/"),
auth=(
settings.mock_api_username,
settings.mock_api_password.get_secret_value(),
),
timeout=settings.mock_api_timeout_seconds,
)
def read_text(payload: dict[str, Any], key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value:
raise ValueError(f"Champ {key} absent ou invalide dans la réponse de l'API Mock.")
return value
def optional_text(value: Any) -> str | None:
return value if isinstance(value, str) else None
def coerce_measure(
value: Any,
bounds: tuple[float, float],
) -> float | None:
if isinstance(value, bool) or not isinstance(value, int | float):
return None
lower, upper = bounds
# Écarte aussi NaN et les infinis, qu'aucune comparaison de bornes ne retient.
return float(value) if lower <= value <= upper else None
def resolve_quality(
value: Any,
rejected: list[str],
) -> str | None:
quality = value if isinstance(value, str) and value in ACCEPTED_QUALITIES else None
if rejected:
return "critical" if quality == "critical" else "degraded"
return quality
def resolve_null_reasons(
value: Any,
rejected: list[str],
) -> list[str]:
reported = [str(reason) for reason in value] if isinstance(value, list) else []
return reported + rejected
async def fetch_sites(
client: httpx.AsyncClient,
) -> list[dict[str, Any]]:
response = await client.get("/api/v1/sites")
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("La réponse /api/v1/sites doit être une liste.")
if len(payload) > MAX_SITES:
raise ValueError(f"La réponse /api/v1/sites dépasse le plafond de {MAX_SITES} sites.")
return payload
def build_site_row(
site: dict[str, Any],
) -> dict[str, Any]:
return {
"site_id": read_text(site, "site_id"),
"site_type": read_text(site, "site_type"),
"site_name": read_text(site, "site_name"),
"location": optional_text(site.get("location")),
"capacity_kw": coerce_measure(site.get("capacity_kw"), CAPACITY_BOUNDS),
"status": optional_text(site.get("status")),
}
async def upsert_sites(
connection: AsyncConnection,
sites: list[dict[str, Any]],
) -> None:
rows = [build_site_row(site) for site in sites]
if not rows:
return
await connection.execute(
text(
"""
INSERT INTO site (
site_id,
site_type,
site_name,
location,
capacity_kw,
status
)
VALUES (
:site_id,
:site_type,
:site_name,
:location,
:capacity_kw,
:status
)
ON CONFLICT (site_id)
DO UPDATE SET
site_type = EXCLUDED.site_type,
site_name = EXCLUDED.site_name,
location = EXCLUDED.location,
capacity_kw = EXCLUDED.capacity_kw,
status = EXCLUDED.status
"""
),
rows,
)
async def fetch_readings(
client: httpx.AsyncClient,
site_id: str,
start_time: datetime,
end_time: datetime,
limit: int = MAX_LIMIT,
) -> list[dict[str, Any]]:
response = await client.get(
"/api/v1/readings",
params={
"site_id": site_id,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"limit": limit,
},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("La réponse /api/v1/readings doit être une liste.")
if len(payload) > limit:
raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.")
return payload
def build_reading_row(
reading: dict[str, Any],
) -> dict[str, Any]:
measures: dict[str, float | None] = {}
rejected: list[str] = []
for name, bounds in PHYSICAL_BOUNDS.items():
received = reading.get(name)
measures[name] = coerce_measure(received, bounds)
if received is not None and measures[name] is None:
rejected.append(f"out_of_physical_bounds:{name}")
return {
"site_id": read_text(reading, "site_id"),
"timestamp": parse_datetime(read_text(reading, "timestamp")),
"source": SOURCE_HISTORY,
"dataset_id": None,
**measures,
"consumption_euros": None,
"solar_irradiance_wm2": None,
"is_working_hours": None,
"data_quality": resolve_quality(reading.get("data_quality"), rejected),
"null_reasons": resolve_null_reasons(reading.get("null_reasons"), rejected),
"imputed_values": None,
"imputation_method": None,
"raw_data": json.dumps(
reading,
ensure_ascii=False,
),
}
# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible
# nommée, DO NOTHING avalerait aussi une violation de clé primaire.
READING_INSERT = text(
"""
INSERT INTO reading (
site_id,
timestamp,
source,
dataset_id,
consumption_kw,
consumption_kwh,
consumption_euros,
voltage_v,
current_a,
power_factor,
temperature_celsius,
humidity_percent,
solar_irradiance_wm2,
is_working_hours,
data_quality,
null_reasons,
imputed_values,
imputation_method,
raw_data
)
VALUES (
:site_id,
:timestamp,
:source,
:dataset_id,
:consumption_kw,
:consumption_kwh,
:consumption_euros,
:voltage_v,
:current_a,
:power_factor,
:temperature_celsius,
:humidity_percent,
:solar_irradiance_wm2,
:is_working_hours,
:data_quality,
:null_reasons,
CAST(:imputed_values AS jsonb),
:imputation_method,
CAST(:raw_data AS jsonb)
)
ON CONFLICT (site_id, timestamp, source, (coalesce(dataset_id, 0)))
DO NOTHING
"""
)
def build_reading_batch(
readings: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return [build_reading_row(reading) for reading in readings]
async def import_mock_api_history(
start_time: datetime,
end_time: datetime,
limit: int,
dry_run: bool,
) -> None:
settings = get_settings()
async with create_mock_api_client() as client:
sites = await fetch_sites(client)
print(f"Sites récupérés : {len(sites)}")
all_readings: list[dict[str, Any]] = []
for site in sites:
site_id = read_text(site, "site_id")
readings = await fetch_readings(
client=client,
site_id=site_id,
start_time=start_time,
end_time=end_time,
limit=limit,
)
print(f"{site_id}: {len(readings)} lectures")
all_readings.extend(readings)
print(f"Lectures récupérées : {len(all_readings)}")
if dry_run:
print("Dry-run terminé : aucune donnée écrite.")
return
engine = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
)
try:
async with engine.begin() as connection:
await upsert_sites(
connection,
sites,
)
rows = build_reading_batch(all_readings)
if rows:
await connection.execute(
READING_INSERT,
rows,
)
finally:
await engine.dispose()
print("Import API Mock terminé.")
def parse_datetime(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=("Import historique depuis l'API Mock EnerVision"))
parser.add_argument(
"--start-time",
required=True,
type=parse_datetime,
)
parser.add_argument(
"--end-time",
required=True,
type=parse_datetime,
)
parser.add_argument(
"--limit",
type=int,
default=MAX_LIMIT,
)
parser.add_argument(
"--dry-run",
action="store_true",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.limit < 1 or args.limit > MAX_LIMIT:
raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.")
if args.start_time >= args.end_time:
raise ValueError("--start-time doit être antérieur à --end-time.")
asyncio.run(
import_mock_api_history(
start_time=args.start_time,
end_time=args.end_time,
limit=args.limit,
dry_run=args.dry_run,
)
)
if __name__ == "__main__":
main()
+2 -39
View File
@@ -1,14 +1,9 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.staticfiles import StaticFiles
from prometheus_fastapi_instrumentator import Instrumentator
from starlette.requests import Request
from starlette.responses import HTMLResponse
from app.api.errors import register_error_handlers
from app.api.middleware import SecurityHeadersMiddleware
@@ -23,8 +18,6 @@ logger = get_logger(__name__)
METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
EN_TETES_AUTORISES = ["Authorization", "Content-Type"]
STATIC_DIR = Path(__file__).parent / "static"
LOGO_URL = "/static/logo-icon.png"
@asynccontextmanager
@@ -50,41 +43,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
openapi_tags=TAGS,
debug=resolved.debug,
lifespan=lifespan,
docs_url=None,
redoc_url=None,
docs_url="/docs" if documentee else None,
redoc_url="/redoc" if documentee else None,
openapi_url="/openapi.json" if documentee else None,
)
if documentee:
application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo
# en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon.
openapi_original = application.openapi
def openapi_avec_logo() -> dict[str, object]:
schema = openapi_original()
schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"}
return schema
application.openapi = openapi_avec_logo # type: ignore[method-assign]
@application.get("/docs", include_in_schema=False)
async def docs_swagger(_: Request) -> HTMLResponse:
return get_swagger_ui_html(
openapi_url="/openapi.json",
title=f"{application.title} · Swagger UI",
swagger_favicon_url=LOGO_URL,
)
@application.get("/redoc", include_in_schema=False)
async def docs_redoc(_: Request) -> HTMLResponse:
return get_redoc_html(
openapi_url="/openapi.json",
title=f"{application.title} · ReDoc",
redoc_favicon_url=LOGO_URL,
)
application.add_middleware(SecurityHeadersMiddleware)
if resolved.allowed_origins:
-4
View File
@@ -4,8 +4,6 @@
from app.models.audit_log import AuditLog
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
from app.models.login_attempt import LoginAttempt
from app.models.password_reset_attempt import PasswordResetAttempt
from app.models.password_reset_token import PasswordResetToken
from app.models.refresh_token import RefreshToken
from app.models.user import AppUser
@@ -15,8 +13,6 @@ __all__ = [
"AuditLog",
"Dataset",
"LoginAttempt",
"PasswordResetAttempt",
"PasswordResetToken",
"Prediction",
"Reading",
"Recommendation",
-2
View File
@@ -29,8 +29,6 @@ class AuditAction(StrEnum):
COMPTE_ACTIVE = "user.enabled"
COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin"
COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed"
MOT_DE_PASSE_OUBLIE_DEMANDE = "auth.password_reset_requested"
MOT_DE_PASSE_REINITIALISE_PAR_SOI = "auth.password_reset_self_service"
REFRESH_REUTILISE = "auth.refresh_reuse_detected"
SESSIONS_REVOQUEES = "auth.all_sessions_revoked"
LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled"
@@ -1,27 +0,0 @@
# Pourquoi : même séparation que `login_attempt` par rapport à `audit_log` : ce compteur est
# piloté par l'attaquant (une campagne de demandes) et se purge, l'audit log est en ajout seul.
# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 apprendrait
# qu'un compte existe.
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Identity, Index, String, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PasswordResetAttempt(Base):
__tablename__ = "password_reset_attempt"
__table_args__ = (
Index("ix_password_reset_attempt_email_date", "email_tried", "occurred_at"),
Index("ix_password_reset_attempt_ip_date", "client_ip", "occurred_at"),
)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
email_tried: Mapped[str] = mapped_column(String(320), nullable=False)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
@@ -1,40 +0,0 @@
# Pourquoi : même schéma que `refresh_token` (chaîne opaque, jamais un JWT) pour la même
# raison : un jeton de réinitialisation doit être révocable d'un coup, et un JWT ne figure
# dans aucune ligne à invalider.
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, Text, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PasswordResetToken(Base):
__tablename__ = "password_reset_token"
__table_args__ = (
Index("ix_password_reset_token_user", "user_id"),
Index(
"ix_password_reset_token_vivants",
"user_id",
postgresql_where="consumed_at is null",
),
)
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()
)
user_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True)
issued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
-55
View File
@@ -1,55 +0,0 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert
class AlertRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> Sequence[Alert]:
requete = select(Alert).order_by(Alert.timestamp.desc(), Alert.alert_id.desc())
if site_id is not None:
requete = requete.where(Alert.site_id == site_id)
if severity is not None:
requete = requete.where(Alert.severity == severity)
return (await self._session.scalars(requete)).all()
async def create_many(self, alerts: Sequence[Alert]) -> Sequence[Alert]:
# `ON CONFLICT DO NOTHING` sur `uq_alert_source_reference` : rejouer la détection sur une
# fenêtre qui recouvre une exécution précédente ne doit pas dupliquer une alerte déjà
# enregistrée. `RETURNING` ne renvoie donc que les lignes effectivement insérées.
if not alerts:
return []
valeurs = [
{
"source_alert_id": alerte.source_alert_id,
"site_id": alerte.site_id,
"source": alerte.source,
"timestamp": alerte.timestamp,
"type": alerte.type,
"severity": alerte.severity,
"message": alerte.message,
"value": alerte.value,
"threshold": alerte.threshold,
"metric": alerte.metric,
"prediction_id": alerte.prediction_id,
"raw_data": alerte.raw_data,
}
for alerte in alerts
]
requete = (
insert(Alert)
.values(valeurs)
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
.returning(Alert)
)
resultat = await self._session.execute(requete)
await self._session.flush()
return resultat.scalars().all()
@@ -1,42 +0,0 @@
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.password_reset_attempt import PasswordResetAttempt
@dataclass(frozen=True, slots=True)
class ResetRequestCounts:
per_identifier: int
per_ip: int
class PasswordResetAttemptRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def record(self, *, email: str, client_ip: str | None) -> None:
self._session.add(
PasswordResetAttempt(email_tried=email.strip().lower(), client_ip=client_ip)
)
async def count_recent(
self, *, email: str, client_ip: str | None, window_seconds: int
) -> ResetRequestCounts:
identifiant = email.strip().lower()
meme_email = PasswordResetAttempt.email_tried == identifiant
meme_ip = PasswordResetAttempt.client_ip == client_ip
requete = select(
func.count().filter(meme_email),
func.count().filter(meme_ip),
).where(
PasswordResetAttempt.occurred_at
> datetime.now(UTC) - timedelta(seconds=window_seconds),
meme_email | meme_ip,
)
par_identifiant, par_ip = (await self._session.execute(requete)).one()
return ResetRequestCounts(per_identifier=par_identifiant, per_ip=par_ip)
@@ -1,78 +0,0 @@
# Piège : `consume()` est une seule instruction, sur le modèle de `claim_for_rotation()` du
# jeton de rafraîchissement. Un SELECT puis un UPDATE laisseraient une fenêtre où deux
# soumissions concurrentes du même lien réussiraient toutes les deux.
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.password_reset_token import PasswordResetToken
@dataclass(frozen=True, slots=True)
class ConsumedResetToken:
id: UUID
user_id: UUID
class PasswordResetTokenRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def create(
self,
*,
user_id: UUID,
token_hash: bytes,
expires_at: datetime,
client_ip: str | None,
user_agent: str | None,
) -> PasswordResetToken:
jeton = PasswordResetToken(
user_id=user_id,
token_hash=token_hash,
expires_at=expires_at,
client_ip=client_ip,
user_agent=user_agent,
)
self._session.add(jeton)
await self._session.flush()
return jeton
async def consume(self, token_hash: bytes) -> ConsumedResetToken | None:
requete = (
update(PasswordResetToken)
.where(
PasswordResetToken.token_hash == token_hash,
PasswordResetToken.consumed_at.is_(None),
PasswordResetToken.expires_at > func.clock_timestamp(),
)
.values(consumed_at=func.clock_timestamp())
.returning(PasswordResetToken.id, PasswordResetToken.user_id)
)
ligne = (await self._session.execute(requete)).one_or_none()
if ligne is None:
return None
return ConsumedResetToken(id=ligne.id, user_id=ligne.user_id)
# Piège : simple SELECT, volontairement pas atomique avec la consommation. Sert seulement
# au feedback UX (jeton encore valide ?) ; `consume()` reste la seule source de vérité.
async def exists_valid(self, token_hash: bytes) -> bool:
requete = select(PasswordResetToken.id).where(
PasswordResetToken.token_hash == token_hash,
PasswordResetToken.consumed_at.is_(None),
PasswordResetToken.expires_at > func.clock_timestamp(),
)
return (await self._session.execute(requete)).first() is not None
async def invalidate_all_for_user(self, user_id: UUID) -> int:
resultat = await self._session.execute(
update(PasswordResetToken)
.where(PasswordResetToken.user_id == user_id, PasswordResetToken.consumed_at.is_(None))
.values(consumed_at=func.clock_timestamp())
.returning(PasswordResetToken.id)
)
return len(resultat.all())
@@ -1,49 +0,0 @@
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Prediction
class PredictionRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_since(
self, *, since: datetime, site_id: str | None = None
) -> Sequence[Prediction]:
# Restreint à `available` : une prévision `insufficient_data`/`error` n'a pas de
# `predicted_value` à comparer à une lecture réelle (détection d'anomalie).
# Piège : `prediction` n'a pas d'unicité sur `(site_id, target_at)` (cf.
# `enervision_ml.score`, qui insère toujours une nouvelle ligne plutôt que d'écraser la
# précédente). `prediction_id` en dernier départage donc les égalités de `target_at` par
# ordre croissant : `_detect_anomaly` construit un dict qui garde le dernier rencontré,
# c'est-à-dire le run le plus récent plutôt qu'une ligne choisie au hasard par le plan
# d'exécution.
requete = (
select(Prediction)
.where(Prediction.target_at >= since, Prediction.status == "available")
.order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id)
)
if site_id is not None:
requete = requete.where(Prediction.site_id == site_id)
return (await self._session.scalars(requete)).all()
async def latest_by_site(self) -> Sequence[Prediction]:
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
# ligne par site, la plus récente grâce à l'ordre composite qui suit. Même mécanisme que
# `ReadingRepository.latest_by_site`. Trié sur `target_at` (couvert par
# `ix_prediction_site_target`) plutôt que `created_at` : c'est la prévision la plus
# récente qui compte pour un tableau de bord, pas forcément le dernier run de scoring.
requete = (
select(Prediction)
.distinct(Prediction.site_id)
.order_by(
Prediction.site_id,
Prediction.target_at.desc(),
Prediction.prediction_id.desc(),
)
)
return (await self._session.scalars(requete)).all()
-70
View File
@@ -1,70 +0,0 @@
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Reading
class ReadingRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def latest_by_site(self) -> Sequence[Reading]:
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
# ligne par site, la plus récente grâce à l'ordre composite qui suit. `reading_id` départage
# les égalités de timestamp, que `uq_reading_source` autorise à `source` différente.
requete = (
select(Reading)
.distinct(Reading.site_id)
.order_by(Reading.site_id, Reading.timestamp.desc(), Reading.reading_id.desc())
)
return (await self._session.execute(requete)).scalars().all()
async def latest_for_site(self, site_id: str) -> Reading | None:
# Piège : `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la
# `source` diffère. Sans `reading_id` en départage, le `LIMIT 1` renverrait au hasard.
requete = (
select(Reading)
.where(Reading.site_id == site_id)
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
.limit(1)
)
lecture: Reading | None = await self._session.scalar(requete)
return lecture
async def list_since(self, *, since: datetime, site_id: str | None = None) -> Sequence[Reading]:
# Trié par site puis par heure croissante : la détection d'alertes (spike) a besoin de
# comparer chaque lecture à celle qui la précède immédiatement pour le même site.
# `reading_id` en dernier départage : `uq_reading_source` autorise deux lignes au même
# `site_id`+`timestamp` quand la `source` diffère (même piège que `latest_for_site`), sans
# quoi l'ordre entre elles ne serait pas garanti d'un appel à l'autre.
requete = (
select(Reading)
.where(Reading.timestamp >= since)
.order_by(Reading.site_id, Reading.timestamp, Reading.reading_id)
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
return (await self._session.scalars(requete)).all()
async def list_history(
self,
*,
start: datetime,
end: datetime,
site_id: str | None = None,
limit: int,
offset: int,
) -> Sequence[Reading]:
requete = (
select(Reading)
.where(Reading.timestamp >= start, Reading.timestamp < end)
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
.limit(limit)
.offset(offset)
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
return (await self._session.scalars(requete)).all()
@@ -1,51 +0,0 @@
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Recommendation
@dataclass(frozen=True, slots=True)
class NouvelleRecommandation:
alert_id: int
action: str
explanation: str
rule_reference: str
TAILLE_DE_LOT = 1000
class RecommendationRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_all(self) -> Sequence[Recommendation]:
requete = select(Recommendation).order_by(Recommendation.recommendation_id)
return (await self._session.scalars(requete)).all()
async def get_by_id(self, recommendation_id: int) -> Recommendation | None:
requete = select(Recommendation).where(
Recommendation.recommendation_id == recommendation_id
)
recommendation: Recommendation | None = await self._session.scalar(requete)
return recommendation
# Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une
# lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion.
async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int:
creees = 0
# Piège : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre
# colonnes. Au-delà de ce seuil un `INSERT` d'un seul tenant échouerait.
for debut in range(0, len(nouvelles), TAILLE_DE_LOT):
requete = (
insert(Recommendation)
.values([asdict(nouvelle) for nouvelle in nouvelles[debut : debut + TAILLE_DE_LOT]])
.on_conflict_do_nothing(constraint="uq_recommendation_alert_rule")
.returning(Recommendation.recommendation_id)
)
creees += len((await self._session.scalars(requete)).all())
return creees
-34
View File
@@ -1,34 +0,0 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
class AlertType(StrEnum):
SPIKE = "spike"
THRESHOLD = "threshold"
ANOMALY = "anomaly"
OUTAGE = "outage"
SENSOR = "sensor"
class AlertSeverity(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class AlertResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
alert_id: int
site_id: str
timestamp: datetime
type: AlertType
severity: AlertSeverity
message: str
value: float | None
threshold: float | None
metric: str | None
prediction_id: int | None
+2 -53
View File
@@ -1,45 +1,17 @@
# Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix
# mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur.
# Contrainte : `SPECIAL_CHARACTERS` doit rester identique à `password.validator.ts` côté
# frontend. `\w`/`\d` divergent entre Python (Unicode) et JavaScript (ASCII) : une classe
# explicite, plutôt qu'une négation, évite qu'un mot de passe soit accepté d'un côté et
# rejeté de l'autre (ex. "Sécurité1", où "é" comptait comme "spécial" pour Python seul).
import re
from typing import Literal, Self
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
PASSWORD_MIN_LENGTH = 8
PASSWORD_MIN_LENGTH = 12
PASSWORD_MAX_LENGTH = 128
SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.?"
_MAJUSCULE = re.compile(r"[A-ZÀ-ÖØ-Þ]")
_MINUSCULE = re.compile(r"[a-zà-öø-þ]")
_CHIFFRE = re.compile(r"[0-9]")
_SPECIAL = re.compile(r"[" + re.escape(SPECIAL_CHARACTERS) + r"]")
def valide_complexite(mot_de_passe: str) -> str:
manquants = [
nom
for nom, motif in (
("une majuscule", _MAJUSCULE),
("une minuscule", _MINUSCULE),
("un chiffre", _CHIFFRE),
("un caractère spécial", _SPECIAL),
)
if not motif.search(mot_de_passe)
]
if manquants:
raise ValueError(f"Le mot de passe doit contenir au moins {', '.join(manquants)}")
return mot_de_passe
class LoginRequest(BaseModel):
email: EmailStr
@@ -50,25 +22,6 @@ class PasswordChangeRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH)
new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH)
@field_validator("new_password")
@classmethod
def _new_password_est_complexe(cls, valeur: str) -> str:
return valide_complexite(valeur)
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str = Field(min_length=1)
new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH)
@field_validator("new_password")
@classmethod
def _new_password_est_complexe(cls, valeur: str) -> str:
return valide_complexite(valeur)
class PrincipalResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -84,10 +37,6 @@ class PrincipalResponse(BaseModel):
return cls.model_validate(principal)
class ResetTokenValidationResponse(BaseModel):
valid: bool
class TokenResponse(BaseModel):
access_token: str
token_type: Literal["bearer"] = "bearer" # noqa: S105
-43
View File
@@ -1,43 +0,0 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
class PredictionTargetMetric(StrEnum):
CONSUMPTION_KWH = "consumption_kwh"
CONSUMPTION_KW = "consumption_kw"
class PredictionStatus(StrEnum):
AVAILABLE = "available"
INSUFFICIENT_DATA = "insufficient_data"
ERROR = "error"
class SitePredictionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
target_at: datetime
target_metric: PredictionTargetMetric
period_minutes: int | None
predicted_value: float | None
status: PredictionStatus
failure_reason: str | None
model_reference: str
created_at: datetime
class SitePredictionSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
prediction: SitePredictionResponse | None
class PredictionSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
sites: list[SitePredictionSummaryResponse]
-45
View File
@@ -1,45 +0,0 @@
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict
class ReadingSource(StrEnum):
CSV = "csv"
API_CURRENT = "api_current"
API_HISTORY = "api_history"
class ReadingDataQuality(StrEnum):
GOOD = "good"
PARTIAL = "partial"
DEGRADED = "degraded"
CRITICAL = "critical"
class ReadingResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
reading_id: int
site_id: str
timestamp: datetime
source: ReadingSource
consumption_kw: float | None
consumption_kwh: float | None
# Piège : `Decimal` (miroir de `Numeric(14, 2)` en base, pour ne pas arrondir un montant)
# sérialise en chaîne dans le JSON, pas en nombre — un consommateur qui ferait un `parseFloat`
# naïf perdrait la précision que ce choix visait à garder.
consumption_euros: Decimal | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
solar_irradiance_wm2: float | None
is_working_hours: bool | None
data_quality: ReadingDataQuality | None
null_reasons: list[str] | None
imputed_values: dict[str, Any] | None
imputation_method: str | None
@@ -1,20 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class RecommendationResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
recommendation_id: int
alert_id: int
action: str
explanation: str
rule_reference: str
created_at: datetime
class RecommendationGenerationResponse(BaseModel):
alerts_examined: int
recommendations_created: int
already_present: int
-42
View File
@@ -1,42 +0,0 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class SensorDiagnosticResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
status: Literal["ok", "failing"]
since: datetime | None = Field(
description=(
"Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la "
"panne : l'historique ne permet pas de le dater sans requête supplémentaire."
)
)
class SiteSensorsResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
consumption: SensorDiagnosticResponse
electrical: SensorDiagnosticResponse
temperature: SensorDiagnosticResponse
humidity: SensorDiagnosticResponse
network: SensorDiagnosticResponse
class SiteSensorStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
sensors: SiteSensorsResponse
overall: Literal["ok", "degraded", "critical"]
class SensorStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
sites: list[SiteSensorStatusResponse]
-20
View File
@@ -1,6 +1,3 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
@@ -13,20 +10,3 @@ class SiteResponse(BaseModel):
location: str | None
capacity_kw: float | None
status: str | None
class SiteCurrentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: Literal["good", "partial", "degraded", "critical"]
-26
View File
@@ -1,26 +0,0 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
class SiteSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
current_consumption_kw: float | None
capacity_kw: float
load_percent: float | None
data_quality: Literal["good", "partial", "degraded", "critical"]
class StatsSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
total_sites: int
total_consumption_kw: float
total_capacity_kw: float
average_load_percent: float
sites: list[SiteSummaryResponse]
-323
View File
@@ -1,323 +0,0 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from app.models.energy import Alert, Prediction, Reading, Site
from app.repositories.alert import AlertRepository
from app.repositories.prediction import PredictionRepository
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
# Fenêtre de lectures/prédictions analysée à chaque exécution : assez large pour couvrir une paire
# de lectures consécutives (spike) et une coupure prolongée (outage), sans réanalyser tout
# l'historique à chaque lancement manuel du script de détection.
LOOKBACK = timedelta(hours=48)
# Cadence nominale d'une lecture : le CSV historique comme l'API Mock livrent un pas horaire.
EXPECTED_INTERVAL = timedelta(hours=1)
# Au-delà de trois pas manqués, on parle de coupure plutôt que d'un simple retard d'ingestion.
OUTAGE_THRESHOLD = EXPECTED_INTERVAL * 3
# +/-50% entre deux lectures consécutives du même site.
SPIKE_RELATIVE_THRESHOLD = 0.5
# 30% d'écart entre la consommation réelle et la prévision du même site/instant.
ANOMALY_RELATIVE_THRESHOLD = 0.3
# Une prévision quasi nulle rend l'écart relatif ininterprétable ; on l'ignore plutôt.
ANOMALY_MINIMUM_PREDICTED_VALUE = 1e-6
THRESHOLD_METRIC = "consumption_kw"
ANOMALY_METRIC = "consumption_kwh"
# `data_quality` -> sévérité du capteur défaillant. `good` est volontairement absent : il ne
# déclenche jamais d'alerte.
QUALITE_VERS_SEVERITE: dict[str, str] = {
"partial": "low",
"degraded": "medium",
"critical": "critical",
}
class AlertService:
def __init__(
self,
*,
alerts: AlertRepository,
readings: ReadingRepository,
predictions: PredictionRepository,
sites: SiteRepository,
) -> None:
self._alerts = alerts
self._readings = readings
self._predictions = predictions
self._sites = sites
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> Sequence[Alert]:
return await self._alerts.list_all(site_id=site_id, severity=severity)
async def detect(
self, *, now: datetime | None = None, site_id: str | None = None
) -> Sequence[Alert]:
"""Compare les lectures/prévisions récentes aux cinq règles internes et enregistre les
alertes déclenchées (`source='enervision'`). Idempotent grâce à `source_alert_id` :
rejouer sur une fenêtre déjà analysée ne recrée pas les mêmes lignes."""
instant = now or datetime.now(UTC)
depuis = instant - LOOKBACK
sites = await self._sites.list_all()
if site_id is not None:
sites = [site for site in sites if site.site_id == site_id]
sites_par_id = {site.site_id: site for site in sites}
if not sites_par_id:
return []
lectures = [
lecture
for lecture in await self._readings.list_since(since=depuis, site_id=site_id)
if lecture.site_id in sites_par_id
]
predictions = [
prediction
for prediction in await self._predictions.list_since(since=depuis, site_id=site_id)
if prediction.site_id in sites_par_id
]
dernieres_lectures = {
lecture.site_id: lecture
for lecture in await self._readings.latest_by_site()
if lecture.site_id in sites_par_id
}
candidates = [
*_detect_threshold(lectures, sites_par_id),
*_detect_spike(lectures),
*_detect_anomaly(lectures, predictions),
*_detect_outage(sites, dernieres_lectures, instant),
*_detect_sensor(lectures),
]
if not candidates:
return []
return await self._alerts.create_many(candidates)
def _severity_from_ratio(ratio: float) -> str:
if ratio >= 2.0:
return "critical"
if ratio >= 1.5:
return "high"
if ratio >= 1.2:
return "medium"
return "low"
def _detect_threshold(lectures: Sequence[Reading], sites_par_id: dict[str, Site]) -> list[Alert]:
# Seuil fixe = la capacité déclarée du site : dépasser `capacity_kw` est un dépassement
# matériel, pas une simple variation, et évite un seuil arbitraire non fourni par le domaine.
alertes = []
for lecture in lectures:
site = sites_par_id[lecture.site_id]
valeur = lecture.consumption_kw
if site.capacity_kw is None or site.capacity_kw <= 0 or valeur is None:
continue
if valeur <= site.capacity_kw:
continue
alertes.append(
Alert(
source_alert_id=f"threshold:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}",
site_id=lecture.site_id,
source="enervision",
timestamp=lecture.timestamp,
type="threshold",
severity=_severity_from_ratio(valeur / site.capacity_kw),
message=(
f"Puissance appelée {valeur:.1f} kW au-dessus de la capacité du site "
f"({site.capacity_kw:.1f} kW)"
),
value=valeur,
threshold=site.capacity_kw,
metric=THRESHOLD_METRIC,
prediction_id=None,
raw_data={},
)
)
return alertes
def _detect_spike(lectures: Sequence[Reading]) -> list[Alert]:
# `lectures` est triée par site, heure puis `reading_id` (cf. `ReadingRepository.list_since`) :
# deux lignes consécutives du même site sont donc deux mesures consécutives dans le temps,
# sauf lorsqu'elles partagent le même horodatage (deux `source` différentes pour le même
# instant, permises par `uq_reading_source`) : ce n'est alors pas une variation réelle, on
# l'ignore plutôt que de générer une fausse alerte figée par son `source_alert_id`.
alertes = []
precedente: Reading | None = None
for lecture in lectures:
if (
precedente is None
or precedente.site_id != lecture.site_id
or precedente.timestamp == lecture.timestamp
):
precedente = lecture
continue
avant, apres = precedente.consumption_kw, lecture.consumption_kw
precedente = lecture
if avant is None or apres is None:
continue
if avant == 0:
# Une variation relative n'a pas de sens depuis zéro, mais un redémarrage direct à
# une consommation positive reste le signal le plus alarmant du lot : `critical`
# plutôt qu'un ratio indéfini.
if apres > 0:
alertes.append(_spike_alert(lecture, avant, apres, severity="critical"))
continue
variation = abs(apres - avant) / abs(avant)
if variation < SPIKE_RELATIVE_THRESHOLD:
continue
alertes.append(
_spike_alert(
lecture,
avant,
apres,
severity=_severity_from_ratio(variation / SPIKE_RELATIVE_THRESHOLD),
)
)
return alertes
def _spike_alert(lecture: Reading, avant: float, apres: float, *, severity: str) -> Alert:
return Alert(
source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}",
site_id=lecture.site_id,
source="enervision",
timestamp=lecture.timestamp,
type="spike",
severity=severity,
message=(
f"Variation brutale entre deux lectures consécutives ({avant:.1f} kW -> {apres:.1f} kW)"
),
value=apres,
threshold=avant,
metric=THRESHOLD_METRIC,
prediction_id=None,
raw_data={},
)
def _detect_anomaly(lectures: Sequence[Reading], predictions: Sequence[Prediction]) -> list[Alert]:
# Alignement strict (site_id, target_at == timestamp) : `enervision_ml.score` produit une
# cible à l'heure pile suivant la dernière lecture, sur la même grille horaire que `reading`.
predictions_par_cle = {
(prediction.site_id, prediction.target_at): prediction
for prediction in predictions
if prediction.target_metric == ANOMALY_METRIC
}
alertes = []
for lecture in lectures:
prediction = predictions_par_cle.get((lecture.site_id, lecture.timestamp))
reel = lecture.consumption_kwh
if prediction is None or reel is None or prediction.predicted_value is None:
continue
predite = prediction.predicted_value
if abs(predite) < ANOMALY_MINIMUM_PREDICTED_VALUE:
continue
ecart = abs(reel - predite) / abs(predite)
if ecart < ANOMALY_RELATIVE_THRESHOLD:
continue
alertes.append(
Alert(
source_alert_id=f"anomaly:{ANOMALY_METRIC}:{lecture.timestamp.isoformat()}",
site_id=lecture.site_id,
source="enervision",
timestamp=lecture.timestamp,
type="anomaly",
severity=_severity_from_ratio(ecart / ANOMALY_RELATIVE_THRESHOLD),
message=(
f"Écart de {ecart * 100:.0f}% entre la consommation mesurée ({reel:.1f} kWh) "
f"et la prévision ({predite:.1f} kWh)"
),
value=reel,
threshold=predite,
metric=ANOMALY_METRIC,
prediction_id=prediction.prediction_id,
raw_data={},
)
)
return alertes
def _detect_outage(
sites: Sequence[Site], dernieres_lectures: dict[str, Reading], now: datetime
) -> list[Alert]:
alertes = []
for site in sites:
derniere = dernieres_lectures.get(site.site_id)
if derniere is None:
alertes.append(
_outage_alert(
site.site_id,
now,
reference=None,
message="Aucune lecture n'a jamais été reçue pour ce site",
severity="critical",
)
)
continue
absence = now - derniere.timestamp
if absence < OUTAGE_THRESHOLD:
continue
alertes.append(
_outage_alert(
site.site_id,
now,
reference=derniere.timestamp,
message=(
f"Aucune lecture depuis {absence} (dernière lecture : "
f"{derniere.timestamp.isoformat()})"
),
severity=_severity_from_ratio(absence / OUTAGE_THRESHOLD),
)
)
return alertes
def _outage_alert(
site_id: str, now: datetime, *, reference: datetime | None, message: str, severity: str
) -> Alert:
return Alert(
source_alert_id=f"outage:{reference.isoformat() if reference is not None else 'jamais'}",
site_id=site_id,
source="enervision",
timestamp=now,
type="outage",
severity=severity,
message=message,
value=None,
threshold=None,
metric=None,
prediction_id=None,
raw_data={},
)
def _detect_sensor(lectures: Sequence[Reading]) -> list[Alert]:
alertes = []
for lecture in lectures:
severite = QUALITE_VERS_SEVERITE.get(lecture.data_quality or "")
if severite is None:
continue
raisons = ", ".join(lecture.null_reasons or []) or "raison non précisée"
alertes.append(
Alert(
source_alert_id=f"sensor:{lecture.timestamp.isoformat()}",
site_id=lecture.site_id,
source="enervision",
timestamp=lecture.timestamp,
type="sensor",
severity=severite,
message=f"Qualité de mesure {lecture.data_quality} ({raisons})",
value=None,
threshold=None,
metric=None,
prediction_id=None,
raw_data={},
)
)
return alertes
-142
View File
@@ -14,11 +14,7 @@ from datetime import UTC, datetime, timedelta
from typing import NoReturn, Protocol
from uuid import UUID, uuid4
from fastapi import BackgroundTasks
from app.core.hashing import Argon2Hasher
from app.core.logging import get_logger
from app.core.mailer import Mailer
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.core.security import (
@@ -32,13 +28,9 @@ from app.models.login_attempt import LoginOutcome
from app.models.refresh_token import RevocationReason
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.user import UserRepository
logger = get_logger(__name__)
class Transaction(Protocol):
async def commit(self) -> None: ...
@@ -62,10 +54,6 @@ class RateLimitedError(AuthError):
self.retry_after = retry_after
class InvalidOrExpiredResetTokenError(AuthError):
pass
@dataclass(frozen=True, slots=True)
class LoginPolicy:
window_seconds: int
@@ -74,15 +62,6 @@ class LoginPolicy:
max_failures_per_identifier: int
@dataclass(frozen=True, slots=True)
class PasswordResetPolicy:
window_seconds: int
max_requests_per_identifier: int
max_requests_per_ip: int
token_ttl: timedelta
frontend_reset_url: str
@dataclass(frozen=True, slots=True)
class AuthenticatedSession:
principal: Principal
@@ -104,10 +83,6 @@ class AuthService:
token_policy: TokenPolicy,
login_policy: LoginPolicy,
refresh_ttl: timedelta,
reset_tokens: PasswordResetTokenRepository,
reset_attempts: PasswordResetAttemptRepository,
reset_policy: PasswordResetPolicy,
mailer: Mailer,
) -> None:
self._users = users
self._attempts = attempts
@@ -118,10 +93,6 @@ class AuthService:
self._token_policy = token_policy
self._login_policy = login_policy
self._refresh_ttl = refresh_ttl
self._reset_tokens = reset_tokens
self._reset_attempts = reset_attempts
self._reset_policy = reset_policy
self._mailer = mailer
async def authenticate(
self, *, email: str, password: str, client_ip: str | None, user_agent: str | None
@@ -229,102 +200,6 @@ class AuthService:
rafraichi = await self._users.get_by_id(principal.id)
return self._session(self._en_principal(rafraichi or compte), secret)
async def request_password_reset(
self,
*,
email: str,
client_ip: str | None,
user_agent: str | None,
background_tasks: BackgroundTasks,
) -> None:
await self._refuse_si_limite_reset(email=email, client_ip=client_ip)
compte = await self._users.get_by_email(email)
# Piège : le hachage factice équilibre le temps de réponse sur un compte inconnu, comme
# `authenticate()`. La réponse et sa forme restent identiques dans tous les cas : compte
# inconnu, compte inactif, ou email envoyé avec succès. L'envoi SMTP lui-même est différé
# en tâche de fond : le laisser dans le chemin de réponse rouvrirait le même oracle par le
# temps (aller-retour réseau) et par la forme (500 si le relais SMTP échoue, contre 202).
if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
await self._hasher.verify_dummy()
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._transaction.commit()
return
await self._reset_tokens.invalidate_all_for_user(compte.id)
secret = generate_refresh_secret()
await self._reset_tokens.create(
user_id=compte.id,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + self._reset_policy.token_ttl,
client_ip=client_ip,
user_agent=user_agent,
)
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._audit.record(
action=AuditAction.MOT_DE_PASSE_OUBLIE_DEMANDE,
actor_label=compte.email,
target_type="app_user",
target_id=str(compte.id),
client_ip=client_ip,
user_agent=user_agent,
)
await self._transaction.commit()
lien = f"{self._reset_policy.frontend_reset_url}?token={secret}"
background_tasks.add_task(self._envoie_email_reset, compte.email, lien)
async def _envoie_email_reset(self, email: str, reset_url: str) -> None:
try:
await self._mailer.send_password_reset_email(to=email, reset_url=reset_url)
except Exception:
logger.exception("auth.password_reset.mail_failed")
# Piège : lecture seule, pas d'appel à `consume()`. Aucune limitation de débit n'est
# nécessaire ici : le jeton est un secret de 256 bits (`generate_refresh_secret`), donc
# non brute-forçable, et cette route n'apprend rien sur l'existence d'un compte ou d'un
# email, seulement si le lien déjà en main du visiteur est encore valide.
async def is_reset_token_valid(self, token: str) -> bool:
return await self._reset_tokens.exists_valid(fingerprint_refresh(token))
async def confirm_password_reset(
self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None
) -> AuthenticatedSession:
revendique = await self._reset_tokens.consume(fingerprint_refresh(token))
if revendique is None:
raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
# Piège : le jeton peut avoir été émis avant une désactivation du compte. Sans cette
# relecture, un lien encore valide (15 min) changerait quand même le mot de passe d'un
# compte désactivé, réutilisable dès sa réactivation.
compte = await self._users.get_by_id(revendique.user_id)
if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value:
raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
await self._users.update_password(
revendique.user_id, await self._hasher.hash(new_password), must_change_password=False
)
revoquees = await self._refresh.revoke_all_for_user(
revendique.user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE
)
secret = await self._ouvre_une_famille(
user_id=revendique.user_id, client_ip=client_ip, user_agent=user_agent
)
await self._audit.record(
action=AuditAction.MOT_DE_PASSE_REINITIALISE_PAR_SOI,
target_type="app_user",
target_id=str(revendique.user_id),
client_ip=client_ip,
user_agent=user_agent,
detail={"sessions_revoquees": revoquees},
)
await self._transaction.commit()
compte = await self._users.get_by_id(revendique.user_id)
if compte is None:
raise SessionRejectedError("Compte introuvable")
return self._session(self._en_principal(compte), secret)
async def logout_all(self, principal: Principal) -> int:
revoquees = await self._refresh.revoke_all_for_user(
principal.id, RevocationReason.DECONNEXION
@@ -432,23 +307,6 @@ class AuthService:
await self._transaction.commit()
raise RateLimitedError(politique.window_seconds)
async def _refuse_si_limite_reset(self, *, email: str, client_ip: str | None) -> None:
politique = self._reset_policy
compteurs = await self._reset_attempts.count_recent(
email=email, client_ip=client_ip, window_seconds=politique.window_seconds
)
depasse = (
compteurs.per_identifier >= politique.max_requests_per_identifier
or compteurs.per_ip >= politique.max_requests_per_ip
)
if not depasse:
return
await self._reset_attempts.record(email=email, client_ip=client_ip)
await self._transaction.commit()
raise RateLimitedError(politique.window_seconds)
async def _echoue(
self,
email: str,
-18
View File
@@ -1,18 +0,0 @@
# Contrainte : `ck_reading_quality` accepte NULL et quatre valeurs seulement, alors que le contrat
# frontend n'a aucune valeur pour l'absence de qualité. `qualite_ou_critique()` replie donc sur
# `critical`, la seule des quatre qui n'induise pas une confiance qu'on n'a pas. `QUALITES_CONNUES`
# reste exposé pour les appelants qui doivent distinguer un `critical` stocké d'un repli.
from typing import Literal, get_args
DataQuality = Literal["good", "partial", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset(get_args(DataQuality))
_PAR_VALEUR: dict[str, DataQuality] = {valeur: valeur for valeur in get_args(DataQuality)}
def qualite_ou_critique(valeur: str | None) -> DataQuality:
if valeur is None:
return "critical"
return _PAR_VALEUR.get(valeur, "critical")
-68
View File
@@ -1,68 +0,0 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from app.models.energy import Prediction, Site
from app.repositories.prediction import PredictionRepository
from app.repositories.site import SiteRepository
@dataclass(frozen=True, slots=True)
class SitePrediction:
target_at: datetime
target_metric: str
period_minutes: int | None
predicted_value: float | None
status: str
failure_reason: str | None
model_reference: str
created_at: datetime
@dataclass(frozen=True, slots=True)
class SitePredictionSummary:
site_id: str
site_name: str
prediction: SitePrediction | None
@dataclass(frozen=True, slots=True)
class PredictionSummary:
timestamp: datetime
sites: list[SitePredictionSummary]
class PredictionService:
def __init__(self, sites: SiteRepository, predictions: PredictionRepository) -> None:
self._sites = sites
self._predictions = predictions
async def summary(self) -> PredictionSummary:
sites = await self._sites.list_all()
dernieres = {p.site_id: p for p in await self._predictions.latest_by_site()}
return PredictionSummary(
timestamp=datetime.now(UTC),
sites=[_resume_site(site, dernieres.get(site.site_id)) for site in sites],
)
def _resume_site(site: Site, derniere: Prediction | None) -> SitePredictionSummary:
# Piège : l'absence de ligne signifie « jamais scoré », pas une valeur pseudo-statut, qui
# n'existe pas dans la contrainte de la table. `prediction` reste `None` plutôt que de
# fabriquer un statut absent du domaine `available`/`insufficient_data`/`error`.
prediction = None
if derniere is not None:
prediction = SitePrediction(
target_at=derniere.target_at,
target_metric=derniere.target_metric,
period_minutes=derniere.period_minutes,
predicted_value=derniere.predicted_value,
status=derniere.status,
failure_reason=derniere.failure_reason,
model_reference=derniere.model_reference,
created_at=derniere.created_at,
)
return SitePredictionSummary(
site_id=site.site_id, site_name=site.site_name, prediction=prediction
)
-59
View File
@@ -1,59 +0,0 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from app.models.energy import Reading
from app.repositories.reading import ReadingRepository
FENETRE_PAR_DEFAUT = timedelta(hours=24)
FENETRE_MAXIMALE = timedelta(days=90)
class FenetreInverseeError(Exception):
"""`start` est postérieur ou égal à `end`."""
class FenetreTropLargeError(Exception):
"""L'écart entre `start` et `end` dépasse `FENETRE_MAXIMALE`."""
class ReadingService:
def __init__(self, *, readings: ReadingRepository) -> None:
self._readings = readings
async def list_history(
self,
*,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int,
offset: int,
) -> Sequence[Reading]:
debut, fin = self._resoudre_fenetre(start, end)
return await self._readings.list_history(
site_id=site_id, start=debut, end=fin, limit=limit, offset=offset
)
@staticmethod
def _resoudre_fenetre(
start: datetime | None, end: datetime | None
) -> tuple[datetime, datetime]:
# Piège : un datetime naïf (sans fuseau dans la chaîne ISO reçue) fait échouer la
# comparaison à `reading.timestamp` (`timestamptz`) au niveau du pilote, en 500 plutôt
# qu'un refus propre. On le traite comme de l'UTC plutôt que de le rejeter.
debut = _vers_utc(start)
fin = _vers_utc(end) or datetime.now(UTC)
if debut is None:
debut = fin - FENETRE_PAR_DEFAUT
if debut >= fin:
raise FenetreInverseeError
if fin - debut > FENETRE_MAXIMALE:
raise FenetreTropLargeError
return debut, fin
def _vers_utc(instant: datetime | None) -> datetime | None:
if instant is None:
return None
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
@@ -1,62 +0,0 @@
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from app.models.energy import Recommendation
from app.repositories.alert import AlertRepository
from app.repositories.recommendation import RecommendationRepository
from app.services.recommendation_rules import applique_les_regles
class Transaction(Protocol):
async def commit(self) -> None: ...
class RecommendationError(Exception):
pass
class RecommendationNotFoundError(RecommendationError):
pass
@dataclass(frozen=True, slots=True)
class RapportGeneration:
alertes_examinees: int
recommandations_creees: int
deja_presentes: int
class RecommendationService:
def __init__(
self,
*,
recommendations: RecommendationRepository,
alerts: AlertRepository,
transaction: Transaction,
) -> None:
self._recommendations = recommendations
self._alerts = alerts
self._transaction = transaction
async def list_all(self) -> Sequence[Recommendation]:
return await self._recommendations.list_all()
async def get_by_id(self, recommendation_id: int) -> Recommendation:
recommendation = await self._recommendations.get_by_id(recommendation_id)
if recommendation is None:
raise RecommendationNotFoundError(recommendation_id)
return recommendation
async def generate(self, *, site_id: str | None = None) -> RapportGeneration:
alertes = await self._alerts.list_all(site_id=site_id)
nouvelles = [nouvelle for alerte in alertes for nouvelle in applique_les_regles(alerte)]
creees = await self._recommendations.create_missing(nouvelles)
await self._transaction.commit()
return RapportGeneration(
alertes_examinees=len(alertes),
recommandations_creees=creees,
deja_presentes=len(nouvelles) - creees,
)
@@ -1,117 +0,0 @@
# Piège : `rule_reference` est la clé d'idempotence en base, portée par la contrainte
# `uq_recommendation_alert_rule`. Renommer une référence déjà livrée ne remplace pas les
# recommandations existantes, il en crée de nouvelles à côté. Une règle qui change de sens
# prend donc une référence suffixée `-v2` - REGLES.
from collections.abc import Callable
from dataclasses import dataclass
from typing import Final
from app.models.energy import Alert
from app.repositories.recommendation import NouvelleRecommandation
from app.schemas.alert import AlertSeverity, AlertType
FACTEUR_DEPASSEMENT_MAJEUR: Final = 1.2
POURCENTAGE_DEPASSEMENT_MAJEUR: Final = round((FACTEUR_DEPASSEMENT_MAJEUR - 1) * 100)
@dataclass(frozen=True, slots=True)
class Regle:
reference: str
action: str
declencheur: Callable[[Alert], bool]
motif: Callable[[Alert], str]
def _du_type(attendu: AlertType) -> Callable[[Alert], bool]:
return lambda alerte: alerte.type == attendu
def _de_severite(attendue: AlertSeverity) -> Callable[[Alert], bool]:
return lambda alerte: alerte.severity == attendue
# Un seuil nul ou négatif rendrait le rapport `value / threshold` arbitraire : l'alerte ne
# renseigne alors aucun dépassement exploitable, et la règle ne se déclenche pas.
def _depasse_largement_le_seuil(alerte: Alert) -> bool:
if alerte.value is None or alerte.threshold is None or alerte.threshold <= 0:
return False
return alerte.value >= alerte.threshold * FACTEUR_DEPASSEMENT_MAJEUR
REGLES: Final[tuple[Regle, ...]] = (
Regle(
reference="spike-delestage-v1",
action="Délester les équipements non prioritaires sur le créneau du pic",
declencheur=_du_type(AlertType.SPIKE),
motif=lambda alerte: f"Pic de consommation signalé sur le site {alerte.site_id}",
),
Regle(
reference="threshold-reduction-v1",
action="Ramener la puissance appelée sous le seuil contractuel",
declencheur=_du_type(AlertType.THRESHOLD),
motif=lambda alerte: f"Seuil de consommation dépassé sur le site {alerte.site_id}",
),
Regle(
reference="outage-secours-v1",
action="Basculer sur l'alimentation de secours et prévenir l'exploitant",
declencheur=_du_type(AlertType.OUTAGE),
motif=lambda alerte: (
f"Risque de surcharge ou de coupure imminente sur le site {alerte.site_id}"
),
),
Regle(
reference="sensor-maintenance-v1",
action="Planifier une intervention de maintenance sur le capteur",
declencheur=_du_type(AlertType.SENSOR),
motif=lambda alerte: (
f"Capteur défaillant sur le site {alerte.site_id}, les mesures ne sont plus fiables"
),
),
Regle(
reference="anomaly-verification-v1",
action="Confronter la mesure à la prévision et vérifier le paramétrage du site",
declencheur=_du_type(AlertType.ANOMALY),
motif=lambda alerte: (
f"Écart anormal entre la mesure et le comportement attendu du site {alerte.site_id}"
),
),
Regle(
reference="escalade-astreinte-v1",
action="Escalader à l'astreinte sous une heure",
declencheur=_de_severite(AlertSeverity.CRITICAL),
motif=lambda alerte: f"Alerte de sévérité critique sur le site {alerte.site_id}",
),
Regle(
reference="contrat-puissance-v1",
action="Réévaluer la puissance souscrite au contrat",
declencheur=_depasse_largement_le_seuil,
motif=lambda alerte: (
f"Dépassement d'au moins {POURCENTAGE_DEPASSEMENT_MAJEUR} % du seuil "
f"sur le site {alerte.site_id}"
),
),
)
def applique_les_regles(alerte: Alert) -> list[NouvelleRecommandation]:
contexte = _contexte_de_mesure(alerte)
return [
NouvelleRecommandation(
alert_id=alerte.alert_id,
action=regle.action,
explanation=f"{regle.motif(alerte)}{contexte}.",
rule_reference=regle.reference,
)
for regle in REGLES
if regle.declencheur(alerte)
]
def _contexte_de_mesure(alerte: Alert) -> str:
if alerte.value is None:
return ""
grandeur = alerte.metric or "valeur"
if alerte.threshold is None:
return f" ({grandeur} mesurée à {alerte.value})"
return f" ({grandeur} mesurée à {alerte.value}, seuil {alerte.threshold})"
-136
View File
@@ -1,136 +0,0 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import qualite_ou_critique
CapteurStatus = Literal["ok", "failing"]
OverallStatus = Literal["ok", "degraded", "critical"]
RAISON_VERS_CAPTEUR: dict[str, str] = {
"consumption_sensor_failure": "consumption",
"electrical_sensor_failure": "electrical",
"temperature_sensor_failure": "temperature",
"humidity_sensor_failure": "humidity",
"network_loss": "network",
}
CHAMPS_PAR_CAPTEUR: dict[str, tuple[str, ...]] = {
"consumption": ("consumption_kw",),
"electrical": ("voltage_v", "current_a", "power_factor"),
"temperature": ("temperature_celsius",),
"humidity": ("humidity_percent",),
}
@dataclass(frozen=True, slots=True)
class DiagnosticCapteur:
status: CapteurStatus
since: datetime | None
@dataclass(frozen=True, slots=True)
class SanteCapteurs:
consumption: DiagnosticCapteur
electrical: DiagnosticCapteur
temperature: DiagnosticCapteur
humidity: DiagnosticCapteur
network: DiagnosticCapteur
@dataclass(frozen=True, slots=True)
class SanteSite:
site_id: str
site_name: str
sensors: SanteCapteurs
overall: OverallStatus
@dataclass(frozen=True, slots=True)
class EtatCapteurs:
timestamp: datetime
sites: list[SanteSite]
class SensorService:
def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def status(self) -> EtatCapteurs:
sites = await self._sites.list_all()
dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()}
return EtatCapteurs(
timestamp=datetime.now(UTC),
sites=[_sante_site(site, dernieres.get(site.site_id)) for site in sites],
)
def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
if derniere is None:
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=_tout_en_echec(since=None),
overall="critical",
)
qualite = qualite_ou_critique(derniere.data_quality)
overall = _overall_depuis_qualite(qualite)
if overall == "critical":
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=_tout_en_echec(since=derniere.timestamp),
overall="critical",
)
raisons_signalees = {
RAISON_VERS_CAPTEUR[raison]
for raison in (derniere.null_reasons or [])
if raison in RAISON_VERS_CAPTEUR
}
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=SanteCapteurs(
consumption=_diagnostic("consumption", derniere, raisons_signalees),
electrical=_diagnostic("electrical", derniere, raisons_signalees),
temperature=_diagnostic("temperature", derniere, raisons_signalees),
humidity=_diagnostic("humidity", derniere, raisons_signalees),
network=_diagnostic("network", derniere, raisons_signalees),
),
overall=overall,
)
def _overall_depuis_qualite(qualite: str) -> OverallStatus:
if qualite == "good":
return "ok"
if qualite in ("partial", "degraded"):
return "degraded"
return "critical"
def _diagnostic(capteur: str, derniere: Reading, raisons_signalees: set[str]) -> DiagnosticCapteur:
champs = CHAMPS_PAR_CAPTEUR.get(capteur, ())
en_echec = capteur in raisons_signalees or any(
getattr(derniere, champ) is None for champ in champs
)
return DiagnosticCapteur(
status="failing" if en_echec else "ok",
since=derniere.timestamp if en_echec else None,
)
def _tout_en_echec(since: datetime | None) -> SanteCapteurs:
echec = DiagnosticCapteur(status="failing", since=since)
return SanteCapteurs(
consumption=echec, electrical=echec, temperature=echec, humidity=echec, network=echec
)
+1 -57
View File
@@ -1,11 +1,7 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from app.models.energy import Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import DataQuality, qualite_ou_critique
class SiteError(Exception):
@@ -16,26 +12,9 @@ class SiteNotFoundError(SiteError):
pass
@dataclass(frozen=True, slots=True)
class SiteCurrentReading:
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: DataQuality
class SiteService:
def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None:
def __init__(self, *, sites: SiteRepository) -> None:
self._sites = sites
self._readings = readings
async def list_all(self) -> Sequence[Site]:
return await self._sites.list_all()
@@ -45,38 +24,3 @@ class SiteService:
if site is None:
raise SiteNotFoundError(site_id)
return site
async def current(self, site_id: str) -> SiteCurrentReading:
site = await self.get_by_id(site_id)
derniere = await self._readings.latest_for_site(site_id)
if derniere is None:
return SiteCurrentReading(
timestamp=None,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=None,
consumption_kwh=None,
voltage_v=None,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
null_reasons=[],
data_quality="critical",
)
return SiteCurrentReading(
timestamp=derniere.timestamp,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=derniere.consumption_kw,
consumption_kwh=derniere.consumption_kwh,
voltage_v=derniere.voltage_v,
current_a=derniere.current_a,
power_factor=derniere.power_factor,
temperature_celsius=derniere.temperature_celsius,
humidity_percent=derniere.humidity_percent,
null_reasons=derniere.null_reasons or [],
data_quality=qualite_ou_critique(derniere.data_quality),
)
-74
View File
@@ -1,74 +0,0 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique
@dataclass(frozen=True, slots=True)
class SiteConsumption:
site_id: str
site_name: str
current_consumption_kw: float | None
capacity_kw: float
load_percent: float | None
data_quality: DataQuality
@dataclass(frozen=True, slots=True)
class ConsumptionSummary:
timestamp: datetime
total_sites: int
total_consumption_kw: float
total_capacity_kw: float
average_load_percent: float
sites: list[SiteConsumption]
class StatsService:
def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def summary(self) -> ConsumptionSummary:
sites = await self._sites.list_all()
dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()}
resumes = [self._resume_site(site, dernieres.get(site.site_id)) for site in sites]
consommation_totale = sum(r.current_consumption_kw or 0 for r in resumes)
capacite_totale = sum(r.capacity_kw for r in resumes)
return ConsumptionSummary(
timestamp=datetime.now(UTC),
total_sites=len(resumes),
total_consumption_kw=consommation_totale,
total_capacity_kw=capacite_totale,
average_load_percent=(
consommation_totale / capacite_totale * 100 if capacite_totale > 0 else 0
),
sites=resumes,
)
@staticmethod
def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption:
capacite = site.capacity_kw or 0
qualite: DataQuality = "critical"
consommation = None
if derniere is not None and derniere.data_quality in QUALITES_CONNUES:
qualite = qualite_ou_critique(derniere.data_quality)
consommation = derniere.consumption_kw
charge = (
consommation / capacite * 100 if consommation is not None and capacite > 0 else None
)
return SiteConsumption(
site_id=site.site_id,
site_name=site.site_name,
current_consumption_kw=consommation,
capacity_kw=capacite,
load_percent=charge,
data_quality=qualite,
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because it is too large Load Diff
+2 -1884
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -16,9 +16,6 @@ dependencies = [
"pyjwt>=2.10",
"argon2-cffi>=23.1",
"anyio>=4.0",
"aiosmtplib>=5.1.3",
"httpx>=0.28.1",
"pandas>=3.0.5",
]
[dependency-groups]
@@ -28,7 +25,7 @@ dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"pandas-stubs>=3.0.5.260914",
"httpx>=0.28.1",
]
[build-system]
-88
View File
@@ -1,88 +0,0 @@
# Pourquoi : classification unique des routes du contrat, lue par test_route_protection.py,
# test_openapi.py et test_matrice_acces.py. Trois listes séparées dérivaient auparavant chacune
# de leur côté, et deux entrées de ROUTES_A_ROLE ne correspondaient plus à aucune route sans que
# rien ne le signale.
# Piège : les trois ensembles doivent rester disjoints et couvrir tout le schéma. C'est
# `test_every_declared_route_is_classified` qui le vérifie, pas la relecture.
from typing import Final
from app.core.roles import Role
Route = tuple[str, str]
ROUTES_PUBLIQUES: Final[frozenset[Route]] = frozenset(
{
("GET", "/api/v1/health/live"),
("GET", "/api/v1/health/ready"),
("POST", "/api/v1/auth/login"),
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
("POST", "/api/v1/auth/logout"),
("POST", "/api/v1/auth/forgot-password"),
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
("POST", "/api/v1/auth/reset-password"),
# Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un
# `Principal`. Le jeton est un secret de 256 bits, non brute-forçable.
("GET", "/api/v1/auth/reset-password/validate"),
("GET", "/metrics"),
}
)
# Le cookie opaque porte seul l'autorisation : sans lui la route rend 401, mais aucun `Principal`
# n'est construit et `require_role` n'entre jamais en jeu.
ROUTE_COOKIE: Final[frozenset[Route]] = frozenset({("POST", "/api/v1/auth/refresh")})
# Authentifiées par `CurrentPrincipalDep` nu, donc hors de `require_role` et, avec lui, hors du
# refus `password_change_required`. Volontaire pour `/auth/password`, qui est la sortie de l'état
# provisoire ; subi pour `/auth/logout-all`, cf. test_matrice_acces.py.
ROUTES_SANS_ROLE: Final[frozenset[Route]] = frozenset(
{
("GET", "/api/v1/auth/me"),
("POST", "/api/v1/auth/password"),
("POST", "/api/v1/auth/logout-all"),
}
)
ROLE_MINIMUM: Final[dict[Route, Role]] = {
("GET", "/api/v1/sites"): Role.LECTEUR,
("GET", "/api/v1/sites/{site_id}"): Role.LECTEUR,
("GET", "/api/v1/sites/{site_id}/current"): Role.LECTEUR,
("GET", "/api/v1/alerts"): Role.LECTEUR,
("GET", "/api/v1/recommendations"): Role.LECTEUR,
("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR,
("POST", "/api/v1/recommendations/generate"): Role.ADMIN,
("GET", "/api/v1/stats/summary"): Role.LECTEUR,
("GET", "/api/v1/readings"): Role.LECTEUR,
("GET", "/api/v1/predictions"): Role.LECTEUR,
("GET", "/api/v1/sensors/status"): Role.ADMIN,
("GET", "/api/v1/users"): Role.ADMIN,
("POST", "/api/v1/users"): Role.ADMIN,
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
("POST", "/api/v1/users/{user_id}/password-reset"): Role.ADMIN,
}
# Piège : `{recommendation_id}` est typé `int` et `{user_id}` est un UUID. Une substitution
# uniforme par une chaîne quelconque rendrait 422 avant d'atteindre la garde de rôle, et le test
# passerait en prouvant autre chose que ce qu'il annonce.
SUBSTITUTIONS: Final[dict[str, str]] = {
"{user_id}": "00000000-0000-0000-0000-000000000000",
"{site_id}": "site-absent-du-jeu-de-donnees",
"{recommendation_id}": "999999999",
}
def chemin_concret(chemin: str) -> str:
for gabarit, valeur in SUBSTITUTIONS.items():
chemin = chemin.replace(gabarit, valeur)
return chemin
def routes_du_schema(schema: dict[str, object]) -> list[Route]:
chemins: dict[str, dict[str, object]] = schema["paths"] # type: ignore[assignment]
return [
(methode.upper(), chemin)
for chemin, operations in chemins.items()
for methode in operations
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
]
-137
View File
@@ -1,137 +0,0 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_alert_service, get_current_principal
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Alert
from app.schemas.alert import AlertSeverity
def principal(role: Role = Role.LECTEUR) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def alert(alert_id: int = 1, site_id: str = "site-1", severity: str = "high") -> Alert:
return Alert(
alert_id=alert_id,
source_alert_id=f"ALR-{alert_id}",
site_id=site_id,
source="enervision",
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
type="threshold",
severity=severity,
message="Dépassement du seuil configuré",
value=812.5,
threshold=720.0,
metric="consumption_kw",
prediction_id=None,
raw_data={},
)
class FauxService:
def __init__(self) -> None:
self.alert = alert()
self.appels: list[tuple[str | None, str | None]] = []
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> list[Alert]:
self.appels.append((site_id, severity))
return [self.alert]
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(app: FastAPI, lecteur_connecte: None) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_alert_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_alert_service, None)
async def test_list_alerts_returns_the_alerts(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/alerts")
assert response.status_code == 200
corps = response.json()
assert corps == [
{
"alert_id": 1,
"site_id": "site-1",
"timestamp": "2026-09-16T00:00:00Z",
"type": "threshold",
"severity": "high",
"message": "Dépassement du seuil configuré",
"value": 812.5,
"threshold": 720.0,
"metric": "consumption_kw",
"prediction_id": None,
}
]
async def test_list_alerts_transmits_the_site_id_filter(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
service = servi()
await client.get("/api/v1/alerts?site_id=site-1")
assert service.appels == [("site-1", None)]
async def test_list_alerts_transmits_the_severity_filter(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
service = servi()
await client.get("/api/v1/alerts?severity=critical")
assert service.appels == [(None, AlertSeverity.CRITICAL)]
async def test_list_alerts_returns_422_for_an_unknown_severity(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/alerts?severity=invalide")
assert response.status_code == 422
async def test_list_alerts_returns_an_empty_list_when_there_is_nothing(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=[])
response = await client.get("/api/v1/alerts")
assert response.status_code == 200
assert response.json() == []
+1 -137
View File
@@ -11,7 +11,6 @@ from app.core.roles import AccountKind, Role
from app.services.auth import (
AuthenticatedSession,
InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
RateLimitedError,
SessionRejectedError,
)
@@ -28,27 +27,15 @@ PRINCIPAL = Principal(
class FauxService:
def __init__(self, erreur: Exception | None = None, *, jeton_valide: bool = True) -> None:
def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur
self._jeton_valide = jeton_valide
async def refresh(self, **_: object) -> AuthenticatedSession:
return await self.authenticate()
async def is_reset_token_valid(self, **_: object) -> bool:
return self._jeton_valide
async def logout(self, **_: object) -> None:
return None
async def request_password_reset(self, **_: object) -> None:
if self._erreur is not None:
raise self._erreur
return None
async def confirm_password_reset(self, **_: object) -> AuthenticatedSession:
return await self.authenticate()
async def authenticate(self, **_: object) -> AuthenticatedSession:
if self._erreur is not None:
raise self._erreur
@@ -219,126 +206,3 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin(
response = await client.post("/api/v1/auth/logout")
assert response.status_code != 403
async def test_forgot_password_answers_202_when_the_account_exists(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"}
)
assert response.status_code == 202
assert response.headers["cache-control"] == "no-store"
async def test_forgot_password_answers_202_identically_when_the_account_is_unknown(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/forgot-password", json={"email": "inconnu@enervision.fr"}
)
assert response.status_code == 202
async def test_forgot_password_returns_429_with_a_retry_after_when_the_rate_limit_is_reached(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
fake_auth_service[0] = RateLimitedError(900)
response = await client.post(
"/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"}
)
assert response.status_code == 429
assert response.headers["retry-after"] == "900"
async def test_forgot_password_rejects_a_malformed_email(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post("/api/v1/auth/forgot-password", json={"email": "pas-un-email"})
assert response.status_code == 422
@pytest.fixture
def fake_auth_service_reset_validity(app: FastAPI) -> Iterator[list[bool]]:
programme = [True]
app.dependency_overrides[get_auth_service] = lambda: FauxService(jeton_valide=programme[0])
yield programme
app.dependency_overrides.pop(get_auth_service, None)
async def test_validate_reset_token_reports_a_living_token(
fake_auth_service_reset_validity: list[bool], client: AsyncClient
) -> None:
response = await client.get(
"/api/v1/auth/reset-password/validate", params={"token": "un-secret-opaque"}
)
assert response.status_code == 200
assert response.json() == {"valid": True}
async def test_validate_reset_token_reports_an_invalid_or_expired_token(
fake_auth_service_reset_validity: list[bool], client: AsyncClient
) -> None:
fake_auth_service_reset_validity[0] = False
response = await client.get(
"/api/v1/auth/reset-password/validate", params={"token": "un-secret-perime"}
)
assert response.status_code == 200
assert response.json() == {"valid": False}
async def test_reset_password_returns_the_token_and_the_cookie_on_success(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/reset-password",
json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"},
)
assert response.status_code == 200
assert response.cookies.get("ev_refresh") is not None
assert "refresh_secret" not in response.text
async def test_reset_password_rejects_an_invalid_or_expired_token(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
fake_auth_service[0] = InvalidOrExpiredResetTokenError("Lien invalide ou expiré")
response = await client.post(
"/api/v1/auth/reset-password",
json={"token": "un-secret-perime", "new_password": "Un-nouveau-mot-de-passe1!"},
)
assert response.status_code == 400
async def test_reset_password_rejects_a_weak_password(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/reset-password",
json={"token": "un-secret-opaque", "new_password": "trop-simple"},
)
assert response.status_code == 422
async def test_reset_password_refuses_a_foreign_origin(
fake_auth_service: list[Exception | None], client: AsyncClient
) -> None:
response = await client.post(
"/api/v1/auth/reset-password",
json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"},
headers={"Origin": "https://malveillant.example"},
)
assert response.status_code == 403
@@ -1,279 +0,0 @@
# Pourquoi : la matrice rôle x route sur les routes réelles. `test_authorization.py` la joue déjà,
# mais contre une route jetable montée par une fixture, ce qui ne dit rien du niveau effectivement
# posé sur `/sites` ou `/users`. `ROLE_MINIMUM` (tests/api/acces.py) est la référence, et ce
# fichier est ce qui la confronte au comportement observé.
# Piège : l'assertion porte sur le refus de la garde, pas sur un 200. Un rôle suffisant peut
# légitimement recevoir 404 ou 422 selon les données ; ce qui compte est qu'il ne reçoive pas le
# 403 `Droits insuffisants`. Sans cette nuance, le test dépendrait du contenu de la base.
# Les tests `integration` en fin de fichier rejouent la même matrice avec de vrais jetons, donc en
# traversant le décodage du JWT et la relecture du compte, ce que l'override court-circuite.
import uuid
from collections.abc import AsyncIterator, Callable, Iterator
import pytest
from fastapi import FastAPI
from httpx import AsyncClient, Response
from sqlalchemy import text
from app.api.deps import get_current_principal
from app.core.hashing import build_hasher
from app.core.principal import Principal
from app.core.roles import AccountKind, Role, has_at_least
from app.db.session import get_session, get_session_factory
from app.repositories.user import UserRepository
from tests.api.acces import ROLE_MINIMUM, chemin_concret
ROLES = [Role.LECTEUR, Role.OPERATEUR, Role.ADMIN]
IDS_DE_ROLE = ["lecteur", "operateur", "admin"]
REFUS_DE_DROITS = "Droits insuffisants"
REFUS_DE_MOT_DE_PASSE = "password_change_required"
MOT_DE_PASSE = "un-mot-de-passe-de-recette"
# `FakeSession` de tests/factories.py rend un unique objet pour les trois formes d'appel, ce qui
# suffit à un test d'endpoint ciblé mais pas à balayer 13 routes qui interrogent chacune la base
# à sa façon. Ce double rend un résultat vide quelle que soit la forme demandée, pour que la
# réponse observée vienne de la garde de rôle et jamais d'un double mal ajusté.
class ResultatVide:
def scalars(self) -> ResultatVide:
return self
def all(self) -> list[object]:
return []
def first(self) -> None:
return None
def one_or_none(self) -> None:
return None
def scalar_one_or_none(self) -> None:
return None
def mappings(self) -> ResultatVide:
return self
def __iter__(self) -> Iterator[object]:
return iter(())
class SessionMuette:
async def scalar(self, *_: object, **__: object) -> None:
return None
async def execute(self, *_: object, **__: object) -> ResultatVide:
return ResultatVide()
async def scalars(self, *_: object, **__: object) -> ResultatVide:
return ResultatVide()
async def get(self, *_: object, **__: object) -> None:
return None
async def flush(self) -> None:
return None
async def commit(self) -> None:
return None
async def rollback(self) -> None:
return None
def add(self, *_: object, **__: object) -> None:
return None
@pytest.fixture
def base_muette(app: FastAPI) -> None:
async def override() -> AsyncIterator[SessionMuette]:
yield SessionMuette()
app.dependency_overrides[get_session] = override
def principal(role: Role, *, must_change_password: bool = False) -> Principal:
return Principal(
id=uuid.uuid4(),
email=f"matrice-{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=must_change_password,
)
@pytest.fixture
def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]:
def installe(acteur: Principal) -> None:
app.dependency_overrides[get_current_principal] = lambda: acteur
yield installe
app.dependency_overrides.pop(get_current_principal, None)
async def appelle(client: AsyncClient, methode: str, chemin: str, **kwargs: object) -> Response:
return await client.request(methode, chemin_concret(chemin), json={}, **kwargs) # type: ignore[arg-type]
def motif_du_refus(response: Response) -> str | None:
if response.status_code != 403:
return None
detail = response.json().get("detail")
return detail if isinstance(detail, str) else None
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
async def test_a_role_below_the_minimum_is_refused_on_every_guarded_route(
connecte: Callable[[Principal], None],
client: AsyncClient,
base_muette: None,
role: Role,
) -> None:
connecte(principal(role))
laissees_passer: list[tuple[str, str, int]] = []
for (methode, chemin), minimum in ROLE_MINIMUM.items():
if has_at_least(role, minimum):
continue
response = await appelle(client, methode, chemin)
if motif_du_refus(response) != REFUS_DE_DROITS:
laissees_passer.append((methode, chemin, response.status_code))
assert laissees_passer == []
# Le pendant du test précédent : sans lui, une garde posée trop haut, par exemple `AdminDep` sur
# `/sites`, ne ferait échouer aucun test du dépôt.
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
async def test_a_role_at_or_above_the_minimum_is_never_refused_by_the_guard(
connecte: Callable[[Principal], None],
client: AsyncClient,
base_muette: None,
role: Role,
) -> None:
connecte(principal(role))
refusees: list[tuple[str, str]] = []
for (methode, chemin), minimum in ROLE_MINIMUM.items():
if not has_at_least(role, minimum):
continue
response = await appelle(client, methode, chemin)
if motif_du_refus(response) == REFUS_DE_DROITS:
refusees.append((methode, chemin))
assert refusees == []
async def test_a_pending_password_change_is_refused_on_every_guarded_route(
connecte: Callable[[Principal], None],
client: AsyncClient,
base_muette: None,
) -> None:
connecte(principal(Role.ADMIN, must_change_password=True))
laissees_passer: list[tuple[str, str, int]] = []
for methode, chemin in ROLE_MINIMUM:
response = await appelle(client, methode, chemin)
if motif_du_refus(response) != REFUS_DE_MOT_DE_PASSE:
laissees_passer.append((methode, chemin, response.status_code))
assert laissees_passer == []
@pytest.fixture
async def comptes_par_role() -> AsyncIterator[dict[Role, str]]:
marque = uuid.uuid4().hex[:12]
hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2)
empreinte = await hacheur.hash(MOT_DE_PASSE)
adresses = {role: f"matrice-{marque}-{role.value}@enervision.fr" for role in ROLES}
async with get_session_factory()() as session:
depot = UserRepository(session)
for role, email in adresses.items():
await depot.create(email=email, password_hash=empreinte, role=role)
await session.commit()
yield adresses
async with get_session_factory()() as session:
await session.execute(
text("delete from app_user where email like :motif"), {"motif": f"matrice-{marque}-%"}
)
await session.commit()
async def authentifie(client: AsyncClient, email: str) -> dict[str, str]:
reponse = await client.post(
"/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE}
)
assert reponse.status_code == 200, reponse.text
return {"Authorization": f"Bearer {reponse.json()['access_token']}"}
@pytest.mark.integration
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
async def test_a_real_token_reaches_exactly_the_routes_of_its_rank(
comptes_par_role: dict[Role, str], client: AsyncClient, role: Role
) -> None:
entetes = await authentifie(client, comptes_par_role[role])
ecarts: list[tuple[str, str, int, str]] = []
for (methode, chemin), minimum in ROLE_MINIMUM.items():
response = await appelle(client, methode, chemin, headers=entetes)
refuse = motif_du_refus(response) == REFUS_DE_DROITS
if refuse is has_at_least(role, minimum):
ecarts.append((methode, chemin, response.status_code, response.text[:120]))
assert ecarts == []
# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture
# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur
# arrive sans que `ROLE_MINIMUM` soit mis à jour.
@pytest.mark.integration
async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank(
comptes_par_role: dict[Role, str], client: AsyncClient
) -> None:
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
divergences: list[tuple[str, str]] = []
for methode, chemin in ROLE_MINIMUM:
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
cote_operateur = await appelle(client, methode, chemin, headers=operateur)
if cote_lecteur.status_code != cote_operateur.status_code:
divergences.append((methode, chemin))
assert divergences == []
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
# `must_change_password` que seul `require_role` applique. Comportement figé ici, pas corrigé.
@pytest.mark.integration
async def test_a_temporary_password_blocks_the_business_routes_but_not_logout_all(
client: AsyncClient,
) -> None:
marque = uuid.uuid4().hex[:12]
email = f"matrice-{marque}-provisoire@enervision.fr"
hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2)
empreinte = await hacheur.hash(MOT_DE_PASSE)
async with get_session_factory()() as session:
await UserRepository(session).create(
email=email, password_hash=empreinte, role=Role.ADMIN, must_change_password=True
)
await session.commit()
try:
entetes = await authentifie(client, email)
sites = await client.get("/api/v1/sites", headers=entetes)
identite = await client.get("/api/v1/auth/me", headers=entetes)
fermeture = await client.post("/api/v1/auth/logout-all", headers=entetes)
assert motif_du_refus(sites) == REFUS_DE_MOT_DE_PASSE
assert identite.status_code == 200
assert fermeture.status_code == 204
finally:
async with get_session_factory()() as session:
await session.execute(text("delete from app_user where email = :e"), {"e": email})
await session.commit()
+8 -6
View File
@@ -8,7 +8,6 @@ from typing import Any
import pytest
from app import cli
from tests.api.acces import ROLE_MINIMUM
METHODES = {"get", "post", "patch", "put", "delete"}
@@ -25,11 +24,14 @@ ORIGINE_VERIFIEE = {
# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour
# `password_change_required`, pas seulement les routes `admin`.
# Piège : cette liste était recopiée ici, et deux de ses entrées portaient `{id}` là où le contrat
# expose `{user_id}`. Elles ne correspondaient donc à aucune opération, et le test ci-dessous
# passait au vert sans rien vérifier sur ces deux routes. Elle est maintenant dérivée, et
# `test_every_declared_route_is_classified` interdit l'entrée morte.
ROUTES_A_ROLE = frozenset(ROLE_MINIMUM)
ROUTES_A_ROLE = {
("GET", "/api/v1/users"),
("POST", "/api/v1/users"),
("PATCH", "/api/v1/users/{id}"),
("POST", "/api/v1/users/{id}/password-reset"),
("GET", "/api/v1/sites"),
("GET", "/api/v1/sites/{site_id}"),
}
@pytest.fixture(scope="module")
@@ -1,85 +0,0 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_prediction_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.services.prediction import PredictionSummary, SitePrediction, SitePredictionSummary
TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC)
CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def lecteur() -> Principal:
# Le garde-fou de rôle (`lecteur` minimum) est déjà couvert par l'ensemble `ROUTES_A_ROLE`
# de `tests/api/test_openapi.py` : pas besoin ici d'un paramètre de rôle jamais appelé avec
# autre chose que sa valeur par défaut.
return Principal(
id=uuid4(),
email="lecteur@enervision.fr",
role=Role.LECTEUR,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
class FauxService:
def __init__(self) -> None:
self.resume = PredictionSummary(
timestamp=datetime.now(UTC),
sites=[
SitePredictionSummary(
site_id="SITE001",
site_name="Bureau Paris La Défense",
prediction=SitePrediction(
target_at=TARGET_AT,
target_metric="consumption_kwh",
period_minutes=60,
predicted_value=812.5,
status="available",
failure_reason=None,
model_reference="lightgbm-abc123",
created_at=CREATED_AT,
),
),
SitePredictionSummary(site_id="SITE002", site_name="Usine Lyon", prediction=None),
],
)
async def summary(self) -> PredictionSummary:
return self.resume
@pytest.fixture
def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_prediction_service] = lambda: service
app.dependency_overrides[get_current_principal] = lambda: lecteur()
return service
yield installe
app.dependency_overrides.pop(get_prediction_service, None)
app.dependency_overrides.pop(get_current_principal, None)
async def test_get_predictions_returns_the_service_result(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/predictions")
assert response.status_code == 200
corps = response.json()
premier, second = corps["sites"]
assert premier["site_id"] == "SITE001"
assert premier["prediction"]["predicted_value"] == 812.5
assert premier["prediction"]["status"] == "available"
assert second["site_id"] == "SITE002"
assert second["prediction"] is None
-198
View File
@@ -1,198 +0,0 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_reading_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Reading
from app.services.reading import FenetreInverseeError, FenetreTropLargeError
def principal(role: Role = Role.LECTEUR) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def reading(reading_id: int = 1, site_id: str = "site-1") -> Reading:
return Reading(
reading_id=reading_id,
site_id=site_id,
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
source="api_current",
consumption_kw=42.5,
consumption_kwh=None,
consumption_euros=None,
voltage_v=230.0,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
solar_irradiance_wm2=None,
is_working_hours=True,
data_quality="good",
null_reasons=None,
imputed_values=None,
imputation_method=None,
raw_data={},
)
class FauxService:
def __init__(self, leve: Exception | None = None) -> None:
self.reading = reading()
self.leve = leve
self.appels: list[tuple[str | None, str | None, str | None, int, int]] = []
async def list_history(
self,
*,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int,
offset: int,
) -> list[Reading]:
self.appels.append((site_id, start, end, limit, offset))
if self.leve is not None:
raise self.leve
return [self.reading]
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(app: FastAPI, lecteur_connecte: None) -> Iterator[Callable[..., FauxService]]:
def installe(*, leve: Exception | None = None) -> FauxService:
service = FauxService(leve=leve)
app.dependency_overrides[get_reading_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_reading_service, None)
async def test_list_readings_returns_the_readings(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings")
assert response.status_code == 200
corps = response.json()
assert corps == [
{
"reading_id": 1,
"site_id": "site-1",
"timestamp": "2026-09-16T00:00:00Z",
"source": "api_current",
"consumption_kw": 42.5,
"consumption_kwh": None,
"consumption_euros": None,
"voltage_v": 230.0,
"current_a": None,
"power_factor": None,
"temperature_celsius": None,
"humidity_percent": None,
"solar_irradiance_wm2": None,
"is_working_hours": True,
"data_quality": "good",
"null_reasons": None,
"imputed_values": None,
"imputation_method": None,
}
]
async def test_list_readings_transmits_the_filters_and_pagination(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
service = servi()
response = await client.get(
"/api/v1/readings",
params={
"site_id": "site-1",
"start": "2026-09-01T00:00:00Z",
"end": "2026-09-02T00:00:00Z",
"limit": 50,
"offset": 10,
},
)
assert response.status_code == 200
assert service.appels == [
(
"site-1",
datetime(2026, 9, 1, tzinfo=UTC),
datetime(2026, 9, 2, tzinfo=UTC),
50,
10,
)
]
async def test_list_readings_returns_400_when_the_window_is_inverted(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(leve=FenetreInverseeError())
response = await client.get("/api/v1/readings")
assert response.status_code == 400
async def test_list_readings_returns_400_when_the_window_is_too_large(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(leve=FenetreTropLargeError())
response = await client.get("/api/v1/readings")
assert response.status_code == 400
async def test_list_readings_returns_422_for_a_limit_above_the_maximum(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings", params={"limit": 5000})
assert response.status_code == 422
async def test_list_readings_returns_422_for_a_negative_offset(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings", params={"offset": -1})
assert response.status_code == 422
async def test_list_readings_returns_an_empty_list_when_there_is_nothing(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=[])
response = await client.get("/api/v1/readings")
assert response.status_code == 200
assert response.json() == []
@@ -1,202 +0,0 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_recommendation_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Recommendation
from app.services.recommendation import RapportGeneration, RecommendationNotFoundError
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
def principal(role: Role = Role.LECTEUR) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def recommendation(recommendation_id: int = 1) -> Recommendation:
return Recommendation(
recommendation_id=recommendation_id,
alert_id=1,
action="Vérifier la consommation",
explanation="Pic détecté",
rule_reference="spike-v1",
created_at=MOMENT,
)
class FauxService:
def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur
self.recommendation = recommendation()
self.site_demande: str | None = None
async def list_all(self) -> list[Recommendation]:
return [self.recommendation]
async def get_by_id(self, recommendation_id: int) -> Recommendation:
if self._erreur is not None:
raise self._erreur
return self.recommendation
async def generate(self, *, site_id: str | None = None) -> RapportGeneration:
self.site_demande = site_id
return RapportGeneration(alertes_examinees=2, recommandations_creees=3, deja_presentes=1)
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(
app: FastAPI, lecteur_connecte: None
) -> Iterator[Callable[[Exception | None], FauxService]]:
def installe(erreur: Exception | None = None) -> FauxService:
service = FauxService(erreur)
app.dependency_overrides[get_recommendation_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_recommendation_service, None)
async def test_list_recommendations_returns_the_recommendations(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/recommendations")
assert response.status_code == 200
corps = response.json()
assert corps == [
{
"recommendation_id": 1,
"alert_id": 1,
"action": "Vérifier la consommation",
"explanation": "Pic détecté",
"rule_reference": "spike-v1",
"created_at": "2024-01-01T00:00:00Z",
}
]
async def test_get_recommendation_returns_the_matching_recommendation(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/recommendations/1")
assert response.status_code == 200
assert response.json()["recommendation_id"] == 1
async def test_get_recommendation_returns_404_for_an_unknown_recommendation(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(RecommendationNotFoundError(404))
response = await client.get("/api/v1/recommendations/404")
assert response.status_code == 404
async def test_list_recommendations_reaches_the_repository_through_the_session(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=[recommendation(1), recommendation(2)])
response = await client.get("/api/v1/recommendations")
assert response.status_code == 200
assert [r["recommendation_id"] for r in response.json()] == [1, 2]
async def test_get_recommendation_reaches_the_repository_through_the_session(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=recommendation(1))
response = await client.get("/api/v1/recommendations/1")
assert response.status_code == 200
assert response.json()["recommendation_id"] == 1
async def test_get_recommendation_returns_404_when_the_session_finds_nothing(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=None)
response = await client.get("/api/v1/recommendations/404")
assert response.status_code == 404
@pytest.fixture
def admin_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal(Role.ADMIN)
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi_en_admin(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_recommendation_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_recommendation_service, None)
async def test_generate_recommendations_returns_the_generation_report(
servi_en_admin: Callable[[], FauxService], client: AsyncClient
) -> None:
servi_en_admin()
response = await client.post("/api/v1/recommendations/generate")
assert response.status_code == 200
assert response.json() == {
"alerts_examined": 2,
"recommendations_created": 3,
"already_present": 1,
}
async def test_generate_recommendations_forwards_the_requested_site(
servi_en_admin: Callable[[], FauxService], client: AsyncClient
) -> None:
service = servi_en_admin()
await client.post("/api/v1/recommendations/generate", params={"site_id": "SITE002"})
assert service.site_demande == "SITE002"
async def test_generate_recommendations_refuses_a_reader(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.post("/api/v1/recommendations/generate")
assert response.status_code == 403
+23 -52
View File
@@ -1,6 +1,6 @@
# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier
# `ROUTES_PUBLIQUES` dans `tests/api/acces.py`, ce qui apparaît en clair dans la diff d'une pull
# request et demande une justification au relecteur.
# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et
# demande une justification au relecteur.
# Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de
# dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route
# peut porter la bonne dépendance tout en répondant quand même.
@@ -11,64 +11,51 @@ import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from tests.api.acces import (
ROLE_MINIMUM,
ROUTE_COOKIE,
ROUTES_PUBLIQUES,
ROUTES_SANS_ROLE,
Route,
chemin_concret,
routes_du_schema,
ROUTES_PUBLIQUES = frozenset(
{
("GET", "/api/v1/health/live"),
("GET", "/api/v1/health/ready"),
("POST", "/api/v1/auth/login"),
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
("POST", "/api/v1/auth/logout"),
("GET", "/metrics"),
}
)
VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000"
STATUTS_DE_REFUS = {401, 403}
HORS_SCHEMA = {("GET", "/metrics")}
def routes_declarees(app: FastAPI) -> list[Route]:
def routes_declarees(app: FastAPI) -> list[tuple[str, str]]:
schema: dict[str, Any] = app.openapi()
return routes_du_schema(schema)
return [
(methode.upper(), chemin)
for chemin, operations in schema["paths"].items()
for methode in operations
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
]
def routes_protegees(app: FastAPI) -> list[Route]:
def routes_protegees(app: FastAPI) -> list[tuple[str, str]]:
return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES]
def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None:
declarees = set(routes_declarees(app)) | HORS_SCHEMA
declarees = set(routes_declarees(app)) | {("GET", "/metrics")}
inconnues = ROUTES_PUBLIQUES - declarees
assert inconnues == set()
# Sans lui, une route ajoutée sans être classée n'est vue par aucun test de rôle : elle hérite
# du seul contrôle anonyme, et une garde posée au mauvais niveau passe inaperçue.
def test_every_declared_route_is_classified(app: FastAPI) -> None:
classees = ROUTES_PUBLIQUES | ROUTE_COOKIE | ROUTES_SANS_ROLE | set(ROLE_MINIMUM)
non_classees = set(routes_declarees(app)) - classees
fantomes = classees - set(routes_declarees(app)) - HORS_SCHEMA
assert non_classees == set(), "classer la route dans tests/api/acces.py"
assert fantomes == set(), "entrée morte : la route n'existe plus sous ce chemin"
def test_the_four_classes_of_routes_stay_disjoint() -> None:
classes = [ROUTES_PUBLIQUES, ROUTE_COOKIE, ROUTES_SANS_ROLE, frozenset(ROLE_MINIMUM)]
for rang, classe in enumerate(classes):
for autre in classes[rang + 1 :]:
assert classe & autre == frozenset()
async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public(
app: FastAPI, client: AsyncClient
) -> None:
ouvertes: list[tuple[str, str, int]] = []
for methode, chemin in routes_protegees(app):
response = await client.request(methode, chemin_concret(chemin), json={})
concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION)
response = await client.request(methode, concret, json={})
if response.status_code not in STATUTS_DE_REFUS:
ouvertes.append((methode, chemin, response.status_code))
@@ -87,19 +74,3 @@ async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None:
)
def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None:
assert ("GET", chemin) in ROUTES_PUBLIQUES
# Piège : ni les routes `include_in_schema=False` (/docs, /redoc) ni un `Mount` Starlette
# (/static) n'apparaissent dans `app.openapi()["paths"]`. `routes_declarees()` ne les voit
# donc jamais, et elles échapperaient silencieusement au garde-fou ci-dessus.
@pytest.mark.parametrize(
"chemin",
["/docs", "/redoc", "/static/logo-icon.png"],
ids=["swagger_ui", "redoc", "logo_statique"],
)
async def test_the_documentation_routes_are_public_by_design(
app: FastAPI, client: AsyncClient, chemin: str
) -> None:
response = await client.get(chemin)
assert response.status_code == 200
-91
View File
@@ -1,91 +0,0 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_sensor_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.services.sensor import DiagnosticCapteur, EtatCapteurs, SanteCapteurs, SanteSite
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def principal(role: Role = Role.ADMIN) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
class FauxService:
def __init__(self) -> None:
ok = DiagnosticCapteur(status="ok", since=None)
en_echec = DiagnosticCapteur(status="failing", since=TIMESTAMP)
self.etat = EtatCapteurs(
timestamp=TIMESTAMP,
sites=[
SanteSite(
site_id="SITE001",
site_name="Bureau Paris La Défense",
sensors=SanteCapteurs(
consumption=ok,
electrical=ok,
temperature=en_echec,
humidity=ok,
network=ok,
),
overall="degraded",
)
],
)
async def status(self) -> EtatCapteurs:
return self.etat
@pytest.fixture
def admin_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_sensor_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_sensor_service, None)
async def test_get_status_returns_the_service_result(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sensors/status")
assert response.status_code == 200
corps = response.json()
assert corps["sites"][0]["site_id"] == "SITE001"
assert corps["sites"][0]["overall"] == "degraded"
assert corps["sites"][0]["sensors"]["temperature"]["status"] == "failing"
assert corps["sites"][0]["sensors"]["consumption"]["status"] == "ok"
async def test_get_status_refuses_a_reader(app: FastAPI, client: AsyncClient) -> None:
app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR)
response = await client.get("/api/v1/sensors/status")
assert response.status_code == 403
+1 -51
View File
@@ -1,5 +1,4 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
@@ -10,9 +9,7 @@ from app.api.deps import get_current_principal, get_site_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Site
from app.services.site import SiteCurrentReading, SiteNotFoundError
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
from app.services.site import SiteNotFoundError
def principal(role: Role = Role.LECTEUR) -> Principal:
@@ -36,28 +33,10 @@ def site(site_id: str = "site-1") -> Site:
)
def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading:
return SiteCurrentReading(
timestamp=TIMESTAMP,
site_id=site_id,
site_type="industriel",
consumption_kw=87.34,
consumption_kwh=87.34,
voltage_v=401.2,
current_a=132.5,
power_factor=0.923,
temperature_celsius=22.1,
humidity_percent=58.4,
null_reasons=[],
data_quality="good",
)
class FauxService:
def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur
self.site = site()
self.actuel = lecture_actuelle()
async def list_all(self) -> list[Site]:
return [self.site]
@@ -67,11 +46,6 @@ class FauxService:
raise self._erreur
return self.site
async def current(self, site_id: str) -> SiteCurrentReading:
if self._erreur is not None:
raise self._erreur
return self.actuel
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
@@ -135,30 +109,6 @@ async def test_get_site_returns_404_for_an_unknown_site(
assert response.status_code == 404
async def test_get_current_returns_the_latest_reading(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sites/site-1/current")
assert response.status_code == 200
corps = response.json()
assert corps["site_id"] == "site-1"
assert corps["data_quality"] == "good"
assert corps["consumption_kw"] == 87.34
async def test_get_current_returns_404_for_an_unknown_site(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(SiteNotFoundError("site-inconnu"))
response = await client.get("/api/v1/sites/site-inconnu/current")
assert response.status_code == 404
async def test_list_sites_reaches_the_repository_through_the_session(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
-73
View File
@@ -1,73 +0,0 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_stats_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.services.stats import ConsumptionSummary, SiteConsumption
def principal(role: Role = Role.LECTEUR) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
class FauxService:
def __init__(self) -> None:
self.resume = ConsumptionSummary(
timestamp=datetime.now(UTC),
total_sites=1,
total_consumption_kw=87.34,
total_capacity_kw=200,
average_load_percent=43.7,
sites=[
SiteConsumption(
site_id="SITE001",
site_name="Bureau Paris La Défense",
current_consumption_kw=87.34,
capacity_kw=200,
load_percent=43.7,
data_quality="good",
)
],
)
async def summary(self) -> ConsumptionSummary:
return self.resume
@pytest.fixture
def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_stats_service] = lambda: service
app.dependency_overrides[get_current_principal] = lambda: principal()
return service
yield installe
app.dependency_overrides.pop(get_stats_service, None)
app.dependency_overrides.pop(get_current_principal, None)
async def test_get_summary_returns_the_service_result(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/stats/summary")
assert response.status_code == 200
corps = response.json()
assert corps["total_sites"] == 1
assert corps["sites"][0]["site_id"] == "SITE001"
assert corps["sites"][0]["data_quality"] == "good"
+19 -29
View File
@@ -112,10 +112,8 @@ async def test_duplicate_reading_is_rejected_when_key_matches(
)
await data_connection.execute(statement)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
async with data_connection.begin_nested():
await data_connection.execute(statement)
@@ -149,12 +147,9 @@ async def test_invalid_reading_is_rejected_when_constraints_fail(
}
values.update(changes)
statement = insert(Reading).values(**values)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
async with data_connection.begin_nested():
await data_connection.execute(insert(Reading).values(**values))
async def test_prediction_requires_period_when_energy_is_predicted(
@@ -169,10 +164,8 @@ async def test_prediction_requires_period_when_energy_is_predicted(
model_reference="test-model/1",
)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
async with data_connection.begin_nested():
await data_connection.execute(statement)
@@ -219,22 +212,21 @@ async def test_alert_rejects_prediction_when_site_differs(
)
).scalar_one()
statement = insert(Alert).values(
source_alert_id=str(uuid4()),
site_id=other_site,
source="enervision",
timestamp=MOMENT,
type="spike",
severity="high",
message="Test",
prediction_id=prediction_id,
raw_data={},
)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
async with data_connection.begin_nested():
await data_connection.execute(
insert(Alert).values(
source_alert_id=str(uuid4()),
site_id=other_site,
source="enervision",
timestamp=MOMENT,
type="spike",
severity="high",
message="Test",
prediction_id=prediction_id,
raw_data={},
)
)
async def test_recommendation_is_unique_when_alert_and_rule_match(
@@ -264,8 +256,6 @@ async def test_recommendation_is_unique_when_alert_and_rule_match(
)
await data_connection.execute(statement)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
async with data_connection.begin_nested():
await data_connection.execute(statement)
@@ -1,245 +0,0 @@
import hashlib
import json
import pandas as pd
import pytest
from app.etl.historical_import import (
SOURCE_NAME,
build_reading_batch,
classify_quality,
compute_sha256,
load_metadata,
normalize_timestamps,
validate_source,
)
def make_metadata() -> dict:
return {
"total_records": 2,
"sites": {
"SITE001": {},
},
}
def make_dataframe() -> pd.DataFrame:
return pd.DataFrame(
[
{
"timestamp": "2023-01-01 00:00:00",
"site_id": "SITE001",
"site_type": "office",
"site_name": "Site 1",
"consumption_kwh": 10.5,
"consumption_euros": 2.5,
"temperature_celsius": 20.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"hour": 0,
"day_of_week": 6,
"day_name": "Sunday",
"month": 1,
"is_weekend": True,
"is_working_hours": False,
},
{
"timestamp": "2023-01-01 01:00:00",
"site_id": "SITE001",
"site_type": "office",
"site_name": "Site 1",
"consumption_kwh": 11.0,
"consumption_euros": 2.7,
"temperature_celsius": 19.5,
"humidity_percent": 52.0,
"solar_irradiance_wm2": 0.0,
"hour": 1,
"day_of_week": 6,
"day_name": "Sunday",
"month": 1,
"is_weekend": True,
"is_working_hours": False,
},
]
)
def test_compute_sha256(tmp_path):
file_path = tmp_path / "dataset.csv"
content = b"hello-enervision"
file_path.write_bytes(content)
expected = hashlib.sha256(content).hexdigest()
assert compute_sha256(file_path) == expected
def test_load_metadata(tmp_path):
metadata_path = tmp_path / "metadata.json"
metadata = {
"total_records": 2,
"sites": {
"SITE001": {},
},
}
metadata_path.write_text(
json.dumps(metadata),
encoding="utf-8",
)
assert load_metadata(metadata_path) == metadata
def test_validate_source_accepts_valid_dataset():
frame = make_dataframe()
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_missing_column():
frame = make_dataframe().drop(columns=["consumption_kwh"])
metadata = make_metadata()
with pytest.raises(
ValueError,
match="Colonnes obligatoires absentes",
):
validate_source(
frame,
metadata,
)
def test_validate_source_rejects_duplicates():
frame = make_dataframe()
frame.loc[1, "timestamp"] = frame.loc[
0,
"timestamp",
]
metadata = make_metadata()
with pytest.raises(
ValueError,
match="doublons",
):
validate_source(
frame,
metadata,
)
def test_validate_source_rejects_unknown_site():
frame = make_dataframe()
frame.loc[1, "site_id"] = "SITE999"
metadata = make_metadata()
with pytest.raises(
ValueError,
match="Sites incohérents",
):
validate_source(
frame,
metadata,
)
def test_normalize_timestamps_adds_timezone():
frame = make_dataframe()
normalized = normalize_timestamps(
frame,
"UTC",
)
assert normalized["timestamp"].dt.tz is not None
assert "_source_timestamp" in normalized.columns
def test_classify_quality_good():
row = make_dataframe().iloc[0].to_dict()
quality, reasons = classify_quality(row)
assert quality == "good"
assert reasons == []
def test_classify_quality_degraded_when_consumption_missing():
row = make_dataframe().iloc[0].to_dict()
row["consumption_kwh"] = None
quality, reasons = classify_quality(row)
assert quality == "degraded"
assert "missing:consumption_kwh" in reasons
def test_build_reading_batch_respects_database_contract():
frame = normalize_timestamps(
make_dataframe(),
"UTC",
)
rows = build_reading_batch(
frame.iloc[:1],
dataset_id=3,
)
assert len(rows) == 1
row = rows[0]
assert row["dataset_id"] == 3
# Important :
# contrainte ck_reading_dataset_source.
assert row["source"] == "csv"
assert SOURCE_NAME == "csv"
# Important :
# contrainte ck_reading_imputation.
assert row["imputed_values"] is None
assert row["imputation_method"] is None
assert row["data_quality"] == "good"
assert row["null_reasons"] == []
def test_build_reading_batch_keeps_missing_values():
frame = make_dataframe()
frame.loc[0, "temperature_celsius"] = None
frame = normalize_timestamps(
frame,
"UTC",
)
rows = build_reading_batch(
frame.iloc[:1],
dataset_id=3,
)
row = rows[0]
assert row["temperature_celsius"] is None
assert "missing:temperature_celsius" in row["null_reasons"]
# RAW ingestion : aucune imputation.
assert row["imputed_values"] is None
assert row["imputation_method"] is None
@@ -1,834 +0,0 @@
import json
import sys
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from httpx import AsyncClient, MockTransport, Request, Response
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
import app.etl.mock_api_import as mock_api_import
from app.etl.mock_api_import import (
MAX_SITES,
READING_INSERT,
SOURCE_HISTORY,
build_reading_batch,
build_reading_row,
build_site_row,
fetch_readings,
fetch_sites,
upsert_sites,
)
def make_site() -> dict[str, Any]:
return {
"site_id": "SITE001",
"site_type": "office",
"site_name": "Bureau Paris La Défense",
"location": "Paris, France",
"capacity_kw": 200,
"status": "active",
}
def make_reading() -> dict[str, Any]:
return {
"timestamp": "2024-06-15T12:00:00Z",
"site_id": "SITE001",
"site_type": "office",
"consumption_kw": 87.34,
"consumption_kwh": 87.34,
"voltage_v": 401.2,
"current_a": 132.5,
"power_factor": 0.923,
"temperature_celsius": 22.1,
"humidity_percent": 58.4,
"null_reasons": [],
"data_quality": "good",
}
async def test_fetch_sites_returns_sites() -> None:
def handler(request: Request) -> Response:
assert request.url.path == "/api/v1/sites"
return Response(
status_code=200,
json=[make_site()],
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
sites = await fetch_sites(client)
assert len(sites) == 1
assert sites[0]["site_id"] == "SITE001"
assert sites[0]["site_type"] == "office"
async def test_fetch_sites_rejects_non_list_response() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=200,
json={"unexpected": "payload"},
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(
ValueError,
match="La réponse /api/v1/sites doit être une liste",
):
await fetch_sites(client)
async def test_fetch_readings_sends_expected_query_parameters() -> None:
captured_params: dict[str, str] = {}
def handler(request: Request) -> Response:
nonlocal captured_params
captured_params = dict(request.url.params)
return Response(
status_code=200,
json=[make_reading()],
)
transport = MockTransport(handler)
start_time = datetime.fromisoformat("2024-06-15T12:00:00")
end_time = datetime.fromisoformat("2024-06-15T13:00:00")
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
readings = await fetch_readings(
client=client,
site_id="SITE001",
start_time=start_time,
end_time=end_time,
limit=60,
)
assert len(readings) == 1
assert captured_params["site_id"] == "SITE001"
assert captured_params["start_time"] == "2024-06-15T12:00:00"
assert captured_params["end_time"] == "2024-06-15T13:00:00"
assert captured_params["limit"] == "60"
async def test_fetch_readings_rejects_non_list_response() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=200,
json={"unexpected": "payload"},
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(
ValueError,
match="La réponse /api/v1/readings doit être une liste",
):
await fetch_readings(
client=client,
site_id="SITE001",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
)
async def test_fetch_readings_raises_on_http_error() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=404,
json={"detail": "Site non trouvé"},
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(httpx.HTTPStatusError):
await fetch_readings(
client=client,
site_id="SITE999",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
)
def test_build_reading_row_respects_database_contract() -> None:
reading = make_reading()
row = build_reading_row(reading)
assert row["site_id"] == "SITE001"
assert row["source"] == SOURCE_HISTORY
assert row["source"] == "api_history"
assert row["dataset_id"] is None
assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00")
assert row["consumption_kw"] == 87.34
assert row["consumption_kwh"] == 87.34
assert row["data_quality"] == "good"
assert row["null_reasons"] == []
assert row["imputed_values"] is None
assert row["imputation_method"] is None
def test_build_reading_row_keeps_null_values_and_quality() -> None:
reading = make_reading()
reading["consumption_kw"] = None
reading["consumption_kwh"] = None
reading["voltage_v"] = None
reading["current_a"] = None
reading["power_factor"] = None
reading["data_quality"] = "degraded"
reading["null_reasons"] = [
"consumption_sensor_failure",
"electrical_sensor_failure",
]
row = build_reading_row(reading)
assert row["consumption_kw"] is None
assert row["consumption_kwh"] is None
assert row["voltage_v"] is None
assert row["current_a"] is None
assert row["power_factor"] is None
assert row["data_quality"] == "degraded"
assert row["null_reasons"] == [
"consumption_sensor_failure",
"electrical_sensor_failure",
]
assert row["imputed_values"] is None
assert row["imputation_method"] is None
def test_build_reading_row_keeps_raw_source_data() -> None:
reading = make_reading()
row = build_reading_row(reading)
raw_data = json.loads(row["raw_data"])
assert raw_data == reading
def test_build_reading_batch_transforms_all_readings() -> None:
first = make_reading()
second = make_reading()
second["timestamp"] = "2024-06-15T12:01:00Z"
second["consumption_kw"] = 90.5
rows = build_reading_batch([first, second])
assert len(rows) == 2
assert rows[0]["site_id"] == "SITE001"
assert rows[0]["consumption_kw"] == 87.34
assert rows[1]["site_id"] == "SITE001"
assert rows[1]["consumption_kw"] == 90.5
def test_create_mock_api_client_requires_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = SimpleNamespace(
mock_api_username=None,
mock_api_password=None,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: settings,
)
with pytest.raises(
ValueError,
match="Les identifiants de l'API Mock ne sont pas configurés",
):
mock_api_import.create_mock_api_client()
async def test_create_mock_api_client_uses_configuration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
password = MagicMock()
password.get_secret_value.return_value = "test-password"
settings = SimpleNamespace(
mock_api_base_url="https://mock.test/",
mock_api_username="test-user",
mock_api_password=password,
mock_api_timeout_seconds=10.0,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: settings,
)
client = mock_api_import.create_mock_api_client()
try:
assert str(client.base_url) == "https://mock.test"
assert client.timeout.connect == 10.0
finally:
await client.aclose()
async def test_upsert_sites_with_empty_list_does_nothing() -> None:
connection = AsyncMock()
await upsert_sites(
connection,
[],
)
connection.execute.assert_not_awaited()
async def test_import_mock_api_history_dry_run_does_not_write(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def handler(request: Request) -> Response:
if request.url.path == "/api/v1/sites":
return Response(
status_code=200,
json=[make_site()],
)
if request.url.path == "/api/v1/readings":
return Response(
status_code=200,
json=[make_reading()],
)
return Response(status_code=404)
transport = MockTransport(handler)
client = AsyncClient(
transport=transport,
base_url="https://mock.test",
)
monkeypatch.setattr(
mock_api_import,
"create_mock_api_client",
lambda: client,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: SimpleNamespace(
database_url="postgresql+asyncpg://unused",
),
)
create_engine_mock = MagicMock()
monkeypatch.setattr(
mock_api_import,
"create_async_engine",
create_engine_mock,
)
await mock_api_import.import_mock_api_history(
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
dry_run=True,
)
create_engine_mock.assert_not_called()
async def test_import_mock_api_history_loads_data(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def handler(request: Request) -> Response:
if request.url.path == "/api/v1/sites":
return Response(
status_code=200,
json=[make_site()],
)
if request.url.path == "/api/v1/readings":
return Response(
status_code=200,
json=[make_reading()],
)
return Response(status_code=404)
transport = MockTransport(handler)
client = AsyncClient(
transport=transport,
base_url="https://mock.test",
)
monkeypatch.setattr(
mock_api_import,
"create_mock_api_client",
lambda: client,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: SimpleNamespace(
database_url="postgresql+asyncpg://test:test@localhost/test",
),
)
connection = AsyncMock()
transaction_context = MagicMock()
transaction_context.__aenter__ = AsyncMock(
return_value=connection,
)
transaction_context.__aexit__ = AsyncMock(
return_value=None,
)
engine = MagicMock()
engine.begin.return_value = transaction_context
engine.dispose = AsyncMock()
create_engine_mock = MagicMock(
return_value=engine,
)
upsert_sites_mock = AsyncMock()
monkeypatch.setattr(
mock_api_import,
"create_async_engine",
create_engine_mock,
)
monkeypatch.setattr(
mock_api_import,
"upsert_sites",
upsert_sites_mock,
)
await mock_api_import.import_mock_api_history(
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
dry_run=False,
)
create_engine_mock.assert_called_once_with(
"postgresql+asyncpg://test:test@localhost/test",
pool_pre_ping=True,
)
upsert_sites_mock.assert_awaited_once_with(
connection,
[make_site()],
)
connection.execute.assert_awaited_once()
engine.dispose.assert_awaited_once()
def test_parse_datetime_accepts_z_suffix() -> None:
result = mock_api_import.parse_datetime(
"2024-06-15T12:00:00Z",
)
assert result == datetime.fromisoformat(
"2024-06-15T12:00:00+00:00",
)
def test_parse_args_reads_cli_parameters(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"mock_api_import",
"--start-time",
"2024-06-15T12:00:00Z",
"--end-time",
"2024-06-15T13:00:00Z",
"--limit",
"60",
"--dry-run",
],
)
args = mock_api_import.parse_args()
assert args.start_time == datetime.fromisoformat(
"2024-06-15T12:00:00+00:00",
)
assert args.end_time == datetime.fromisoformat(
"2024-06-15T13:00:00+00:00",
)
assert args.limit == 60
assert args.dry_run is True
def test_main_rejects_limit_out_of_bounds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"mock_api_import",
"--start-time",
"2024-06-15T12:00:00Z",
"--end-time",
"2024-06-15T13:00:00Z",
"--limit",
"0",
],
)
with pytest.raises(
ValueError,
match="--limit doit être compris entre 1 et 1000",
):
mock_api_import.main()
def test_main_rejects_invalid_period(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"mock_api_import",
"--start-time",
"2024-06-15T14:00:00Z",
"--end-time",
"2024-06-15T13:00:00Z",
"--limit",
"60",
],
)
with pytest.raises(
ValueError,
match="--start-time doit être antérieur à --end-time",
):
mock_api_import.main()
def test_main_runs_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
start_time = datetime.fromisoformat(
"2024-06-15T12:00:00+00:00",
)
end_time = datetime.fromisoformat(
"2024-06-15T13:00:00+00:00",
)
import_mock = AsyncMock()
monkeypatch.setattr(
mock_api_import,
"parse_args",
lambda: SimpleNamespace(
start_time=start_time,
end_time=end_time,
limit=60,
dry_run=True,
),
)
monkeypatch.setattr(
mock_api_import,
"import_mock_api_history",
import_mock,
)
mock_api_import.main()
import_mock.assert_awaited_once_with(
start_time=start_time,
end_time=end_time,
limit=60,
dry_run=True,
)
async def test_fetch_sites_rejects_a_response_above_the_cap() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=200,
json=[make_site() for _ in range(MAX_SITES + 1)],
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(
ValueError,
match=f"dépasse le plafond de {MAX_SITES} sites",
):
await fetch_sites(client)
async def test_fetch_readings_rejects_a_response_above_the_requested_limit() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=200,
json=[make_reading(), make_reading(), make_reading()],
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(
ValueError,
match="dépasse la limite demandée de 2",
):
await fetch_readings(
client=client,
site_id="SITE001",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=2,
)
def test_build_reading_row_neutralises_values_outside_physical_bounds() -> None:
reading = make_reading()
reading["power_factor"] = 42.0
reading["temperature_celsius"] = 1e30
reading["humidity_percent"] = -1.0
row = build_reading_row(reading)
assert row["power_factor"] is None
assert row["temperature_celsius"] is None
assert row["humidity_percent"] is None
assert row["null_reasons"] == [
"out_of_physical_bounds:power_factor",
"out_of_physical_bounds:temperature_celsius",
"out_of_physical_bounds:humidity_percent",
]
assert row["data_quality"] == "degraded"
assert json.loads(row["raw_data"])["power_factor"] == 42.0
def test_build_reading_row_rejects_a_measure_that_is_not_a_number() -> None:
reading = make_reading()
reading["consumption_kw"] = "87.34"
row = build_reading_row(reading)
assert row["consumption_kw"] is None
assert "out_of_physical_bounds:consumption_kw" in row["null_reasons"]
def test_build_reading_row_drops_a_quality_the_database_refuses() -> None:
reading = make_reading()
reading["data_quality"] = "unknown"
row = build_reading_row(reading)
assert row["data_quality"] is None
def test_build_reading_row_requires_an_identifier() -> None:
reading = make_reading()
del reading["site_id"]
with pytest.raises(
ValueError,
match="Champ site_id absent ou invalide",
):
build_reading_row(reading)
def test_build_site_row_keeps_only_the_expected_columns() -> None:
site = make_site()
site["unexpected"] = "valeur hostile"
site["capacity_kw"] = -5.0
site["status"] = 12
row = build_site_row(site)
assert set(row) == {
"site_id",
"site_type",
"site_name",
"location",
"capacity_kw",
"status",
}
assert row["capacity_kw"] is None
assert row["status"] is None
async def test_upsert_sites_sends_only_the_expected_columns() -> None:
connection = AsyncMock()
site = make_site()
site["unexpected"] = "valeur hostile"
await upsert_sites(
connection,
[site],
)
rows = connection.execute.await_args.args[1]
assert "unexpected" not in rows[0]
assert rows[0]["site_id"] == "SITE001"
@pytest.mark.integration
async def test_reading_insert_is_idempotent(
session: AsyncSession,
) -> None:
reading = make_reading()
row = build_reading_row(reading)
connection = await session.connection()
await upsert_sites(
connection,
[make_site()],
)
await session.execute(
READING_INSERT,
[row],
)
await session.execute(
READING_INSERT,
[row],
)
result = await session.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE site_id = :site_id
AND timestamp = :timestamp
AND source = :source
"""
),
{
"site_id": row["site_id"],
"timestamp": row["timestamp"],
"source": row["source"],
},
)
assert result.scalar_one() == 1
await session.rollback()
@pytest.mark.integration
async def test_out_of_bounds_reading_is_stored_neutralised(
session: AsyncSession,
) -> None:
reading = make_reading()
reading["power_factor"] = 42.0
row = build_reading_row(reading)
connection = await session.connection()
await upsert_sites(
connection,
[make_site()],
)
await session.execute(
READING_INSERT,
[row],
)
result = await session.execute(
text(
"""
SELECT power_factor, data_quality, null_reasons, raw_data ->> 'power_factor'
FROM reading
WHERE site_id = :site_id
AND timestamp = :timestamp
AND source = :source
"""
),
{
"site_id": row["site_id"],
"timestamp": row["timestamp"],
"source": row["source"],
},
)
stored = result.one()
await session.rollback()
assert stored[0] is None
assert stored[1] == "degraded"
assert stored[2] == ["out_of_physical_bounds:power_factor"]
assert stored[3] == "42.0"
@@ -1,148 +0,0 @@
import uuid
from datetime import UTC, datetime
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert
from app.repositories.alert import AlertRepository
from app.schemas.alert import AlertSeverity
from tests.repositories.test_site import creer as creer_site
from tests.repositories.test_site import identifiant as identifiant_site
pytestmark = pytest.mark.integration
async def creer_alerte(session: AsyncSession, *, site_id: str, **overrides: object) -> Alert:
alerte = Alert(
source_alert_id=overrides.get("source_alert_id", f"ALR-{uuid.uuid4().hex[:12]}"),
site_id=site_id,
source=overrides.get("source", "enervision"),
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
type=overrides.get("type", "threshold"),
severity=overrides.get("severity", "high"),
message=overrides.get("message", "Dépassement du seuil configuré"),
value=overrides.get("value", 812.5),
threshold=overrides.get("threshold", 720.0),
metric=overrides.get("metric", "consumption_kw"),
prediction_id=overrides.get("prediction_id"),
raw_data=overrides.get("raw_data", {}),
)
session.add(alerte)
await session.flush()
return alerte
async def test_list_all_returns_the_alerts_sorted_by_timestamp_descending(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = AlertRepository(session)
ancienne = await creer_alerte(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)
)
recente = await creer_alerte(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
)
alertes = await depot.list_all()
identifiants = [
a.alert_id for a in alertes if a.alert_id in (ancienne.alert_id, recente.alert_id)
]
await session.rollback()
assert identifiants == [recente.alert_id, ancienne.alert_id]
async def test_list_all_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = AlertRepository(session)
voulue = await creer_alerte(session, site_id=premier.site_id)
await creer_alerte(session, site_id=second.site_id)
alertes = await depot.list_all(site_id=premier.site_id)
identifiants = [a.alert_id for a in alertes]
await session.rollback()
assert identifiants == [voulue.alert_id]
async def test_list_all_filters_by_severity(session: AsyncSession) -> None:
site = await creer_site(session)
depot = AlertRepository(session)
voulue = await creer_alerte(session, site_id=site.site_id, severity="critical")
await creer_alerte(session, site_id=site.site_id, severity="low")
alertes = await depot.list_all(severity=AlertSeverity.CRITICAL)
identifiants = [a.alert_id for a in alertes]
await session.rollback()
assert identifiants == [voulue.alert_id]
async def test_list_all_returns_an_empty_list_when_there_is_nothing(
session: AsyncSession,
) -> None:
depot = AlertRepository(session)
alertes = await depot.list_all(site_id=identifiant_site())
assert list(alertes) == []
def _alerte_a_inserer(*, site_id: str, source_alert_id: str) -> Alert:
return Alert(
source_alert_id=source_alert_id,
site_id=site_id,
source="enervision",
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
type="threshold",
severity="high",
message="Dépassement du seuil configuré",
value=812.5,
threshold=720.0,
metric="consumption_kw",
prediction_id=None,
raw_data={},
)
async def test_create_many_inserts_every_alert(session: AsyncSession) -> None:
site = await creer_site(session)
depot = AlertRepository(session)
creees = await depot.create_many(
[
_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:a"),
_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:b"),
]
)
identifiants = [a.alert_id for a in creees]
await session.rollback()
assert len(identifiants) == 2
assert all(identifiant is not None for identifiant in identifiants)
async def test_create_many_skips_a_duplicate_source_alert_id(session: AsyncSession) -> None:
site = await creer_site(session)
depot = AlertRepository(session)
await depot.create_many(
[_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:rejouee")]
)
rejouees = await depot.create_many(
[_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:rejouee")]
)
await session.rollback()
assert rejouees == []
async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession) -> None:
depot = AlertRepository(session)
creees = await depot.create_many([])
assert creees == []
@@ -49,10 +49,8 @@ async def test_the_database_refuses_to_mutate_the_audit_log(
) -> None:
await une_ligne(session)
requete = text(instruction)
with pytest.raises(DBAPIError, match="ajout seul"):
await session.execute(requete)
await session.execute(text(instruction))
await session.rollback()
@@ -1,145 +0,0 @@
# Le premier test démontre l'atomicité de `consume()` : sur un double, deux soumissions
# concurrentes du même lien réussiraient toutes les deux.
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.roles import Role
from app.core.security import fingerprint_refresh, generate_refresh_secret
from app.repositories.password_reset_token import PasswordResetTokenRepository
from app.repositories.user import UserRepository
pytestmark = pytest.mark.integration
DUREE = timedelta(minutes=15)
async def un_compte(session: AsyncSession) -> uuid.UUID:
compte = await UserRepository(session).create(
email=f"reset-{uuid.uuid4().hex[:12]}@enervision.fr",
password_hash="$argon2id$x",
role=Role.LECTEUR,
)
return compte.id
async def un_jeton(
depot: PasswordResetTokenRepository, user_id: uuid.UUID, *, duree: timedelta = DUREE
) -> str:
secret = generate_refresh_secret()
await depot.create(
user_id=user_id,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + duree,
client_ip="203.0.113.10",
user_agent="pytest",
)
return secret
async def test_consume_only_succeeds_once(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
secret = await un_jeton(depot, await un_compte(session))
premier = await depot.consume(fingerprint_refresh(secret))
second = await depot.consume(fingerprint_refresh(secret))
await session.rollback()
assert premier is not None
assert second is None
async def test_consume_refuses_an_expired_token(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1))
revendique = await depot.consume(fingerprint_refresh(secret))
await session.rollback()
assert revendique is None
async def test_consume_returns_nothing_for_an_unknown_fingerprint(
session: AsyncSession,
) -> None:
revendique = await PasswordResetTokenRepository(session).consume(
fingerprint_refresh(generate_refresh_secret())
)
assert revendique is None
async def test_invalidate_all_for_user_only_touches_living_tokens(
session: AsyncSession,
) -> None:
depot = PasswordResetTokenRepository(session)
compte = await un_compte(session)
await un_jeton(depot, compte)
await un_jeton(depot, compte)
invalides = await depot.invalidate_all_for_user(compte)
second_passage = await depot.invalidate_all_for_user(compte)
await session.rollback()
assert invalides == 2
assert second_passage == 0
async def test_exists_valid_is_true_for_a_living_token(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
secret = await un_jeton(depot, await un_compte(session))
assert await depot.exists_valid(fingerprint_refresh(secret)) is True
async def test_exists_valid_is_false_for_an_expired_token(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1))
assert await depot.exists_valid(fingerprint_refresh(secret)) is False
async def test_exists_valid_is_false_once_the_token_is_consumed(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
secret = await un_jeton(depot, await un_compte(session))
await depot.consume(fingerprint_refresh(secret))
assert await depot.exists_valid(fingerprint_refresh(secret)) is False
async def test_exists_valid_is_false_for_an_unknown_fingerprint(session: AsyncSession) -> None:
depot = PasswordResetTokenRepository(session)
assert await depot.exists_valid(fingerprint_refresh(generate_refresh_secret())) is False
async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
session: AsyncSession,
) -> None:
depot = PasswordResetTokenRepository(session)
compte = await un_compte(session)
secret = generate_refresh_secret()
await depot.create(
user_id=compte,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + DUREE,
client_ip=None,
user_agent=None,
)
empreinte = fingerprint_refresh(secret)
expiration = datetime.now(UTC) + DUREE
with pytest.raises(IntegrityError):
await depot.create(
user_id=compte,
token_hash=empreinte,
expires_at=expiration,
client_ip=None,
user_agent=None,
)
await session.rollback()
@@ -1,171 +0,0 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Prediction
from app.repositories.prediction import PredictionRepository
from tests.repositories.test_site import creer as creer_site
from tests.repositories.test_site import identifiant as identifiant_site
pytestmark = pytest.mark.integration
async def creer_prediction(
session: AsyncSession, *, site_id: str, **overrides: object
) -> Prediction:
prediction = Prediction(
site_id=site_id,
target_at=overrides.get("target_at", datetime(2026, 9, 16, tzinfo=UTC)),
target_metric=overrides.get("target_metric", "consumption_kwh"),
period_minutes=overrides.get("period_minutes", 60),
predicted_value=overrides.get("predicted_value", 42.0),
model_reference=overrides.get("model_reference", "lightgbm-test"),
status=overrides.get("status", "available"),
failure_reason=overrides.get("failure_reason"),
)
session.add(prediction)
await session.flush()
return prediction
async def test_list_since_excludes_predictions_before_the_cutoff(session: AsyncSession) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
dedans = await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 16, tzinfo=UTC)
)
await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC)
)
resultats = await depot.list_since(
since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id
)
identifiants = [p.prediction_id for p in resultats]
await session.rollback()
assert identifiants == [dedans.prediction_id]
async def test_list_since_excludes_predictions_that_are_not_available(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
await creer_prediction(
session,
site_id=site.site_id,
target_at=datetime(2026, 9, 16, tzinfo=UTC),
status="insufficient_data",
predicted_value=None,
failure_reason="pas assez d'historique",
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
await session.rollback()
assert list(resultats) == []
async def test_list_since_breaks_a_target_at_tie_by_ascending_prediction_id(
session: AsyncSession,
) -> None:
# `prediction` n'a pas d'unicité sur `(site_id, target_at)` : deux runs de scoring sans
# nouvelle lecture entre-temps produisent deux lignes `available` à la même cible. Sans ce
# départage, `_detect_anomaly` retiendrait une ligne au hasard plutôt que le run le plus
# récent.
site = await creer_site(session)
depot = PredictionRepository(session)
cible = datetime(2026, 9, 16, tzinfo=UTC)
premier_run = await creer_prediction(
session, site_id=site.site_id, target_at=cible, predicted_value=10.0
)
second_run = await creer_prediction(
session, site_id=site.site_id, target_at=cible, predicted_value=20.0
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [p.prediction_id for p in resultats]
await session.rollback()
assert identifiants == [premier_run.prediction_id, second_run.prediction_id]
async def test_list_since_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = PredictionRepository(session)
voulue = await creer_prediction(session, site_id=premier.site_id)
await creer_prediction(session, site_id=second.site_id)
resultats = await depot.list_since(
since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id
)
identifiants = [p.prediction_id for p in resultats]
await session.rollback()
assert identifiants == [voulue.prediction_id]
async def test_latest_by_site_keeps_only_the_most_recent_target(session: AsyncSession) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
ancienne = await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC)
)
recente = await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 15, tzinfo=UTC)
)
resultats = await depot.latest_by_site()
identifiants = [
p.prediction_id
for p in resultats
if p.prediction_id in (ancienne.prediction_id, recente.prediction_id)
]
await session.rollback()
assert identifiants == [recente.prediction_id]
async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = PredictionRepository(session)
voulue_premier = await creer_prediction(session, site_id=premier.site_id)
voulue_second = await creer_prediction(session, site_id=second.site_id)
resultats = await depot.latest_by_site()
identifiants = {p.site_id for p in resultats if p.site_id in (premier.site_id, second.site_id)}
await session.rollback()
assert identifiants == {voulue_premier.site_id, voulue_second.site_id}
async def test_latest_by_site_keeps_an_insufficient_data_prediction(session: AsyncSession) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
voulue = await creer_prediction(
session,
site_id=site.site_id,
status="insufficient_data",
predicted_value=None,
failure_reason="pas assez d'historique",
)
resultats = await depot.latest_by_site()
identifiants = [p.prediction_id for p in resultats if p.site_id == site.site_id]
await session.rollback()
assert identifiants == [voulue.prediction_id]
async def test_latest_by_site_returns_an_empty_list_when_there_is_nothing(
session: AsyncSession,
) -> None:
depot = PredictionRepository(session)
resultats = [p for p in await depot.latest_by_site() if p.site_id == identifiant_site()]
assert resultats == []
@@ -1,335 +0,0 @@
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from tests.repositories.test_site import creer as creer_site
from tests.repositories.test_site import identifiant as identifiant_site
pytestmark = pytest.mark.integration
def identifiant() -> str:
return f"SITE-{uuid.uuid4().hex[:8]}"
def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Reading:
return Reading(
site_id=site_id,
timestamp=timestamp,
source="api_current",
consumption_kw=consumption_kw,
data_quality="good",
raw_data={},
)
async def creer_lecture(session: AsyncSession, *, site_id: str, **overrides: object) -> Reading:
reading = Reading(
site_id=site_id,
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
source=overrides.get("source", "api_current"),
consumption_kw=overrides.get("consumption_kw", 10.0),
data_quality=overrides.get("data_quality", "good"),
raw_data=overrides.get("raw_data", {}),
)
session.add(reading)
await session.flush()
return reading
async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None:
site_id = identifiant()
maintenant = datetime.now(UTC)
session.add(Site(site_id=site_id, site_name="Site", site_type="bureau", capacity_kw=100))
await session.flush()
session.add_all(
[
lecture(site_id, timestamp=maintenant - timedelta(hours=1), consumption_kw=10),
lecture(site_id, timestamp=maintenant, consumption_kw=42),
]
)
await session.flush()
depot = ReadingRepository(session)
resultats = await depot.latest_by_site()
consommations = [r.consumption_kw for r in resultats if r.site_id == site_id]
await session.rollback()
assert consommations == [42]
async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None:
premier, second = identifiant(), identifiant()
maintenant = datetime.now(UTC)
session.add_all(
[
Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=100),
Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=200),
]
)
await session.flush()
session.add_all(
[
lecture(premier, timestamp=maintenant, consumption_kw=10),
lecture(second, timestamp=maintenant, consumption_kw=20),
]
)
await session.flush()
depot = ReadingRepository(session)
resultats = await depot.latest_by_site()
identifiants = {r.site_id for r in resultats if r.site_id in (premier, second)}
await session.rollback()
assert identifiants == {premier, second}
async def test_latest_by_site_breaks_a_timestamp_tie_on_the_last_written_reading(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
)
derniere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
)
resultats = await depot.latest_by_site()
retenues = [r.reading_id for r in resultats if r.site_id == site.site_id]
await session.rollback()
assert retenues == [derniere.reading_id]
async def test_latest_for_site_returns_the_most_recent_reading(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC))
recente = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
)
trouvee = await depot.latest_for_site(site.site_id)
reading_id = trouvee.reading_id if trouvee else None
await session.rollback()
assert reading_id == recente.reading_id
async def test_latest_for_site_breaks_a_timestamp_tie_on_the_last_written_reading(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
await creer_lecture(session, site_id=site.site_id, timestamp=horodatage, source="api_history")
derniere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current"
)
trouvee = await depot.latest_for_site(site.site_id)
reading_id = trouvee.reading_id if trouvee else None
await session.rollback()
assert reading_id == derniere.reading_id
async def test_latest_for_site_ignores_the_readings_of_the_other_sites(
session: AsyncSession,
) -> None:
sans_lecture = await creer_site(session)
autre = await creer_site(session)
depot = ReadingRepository(session)
await creer_lecture(session, site_id=autre.site_id)
trouvee = await depot.latest_for_site(sans_lecture.site_id)
await session.rollback()
assert trouvee is None
async def test_list_since_orders_by_site_then_by_time_ascending(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
plus_recente = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC)
)
plus_ancienne = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [plus_ancienne.reading_id, plus_recente.reading_id]
async def test_list_since_excludes_readings_before_the_cutoff(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
dedans = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC)
)
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC))
resultats = await depot.list_since(
since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [dedans.reading_id]
async def test_list_since_breaks_a_timestamp_tie_by_ascending_reading_id(
session: AsyncSession,
) -> None:
# `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la `source`
# diffère (même piège que `latest_for_site`). Sans ce départage, `_detect_spike` traiterait
# cette paire comme une variation réelle selon un ordre non garanti par le plan d'exécution.
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 16, tzinfo=UTC)
premiere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
)
seconde = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [premiere.reading_id, seconde.reading_id]
async def test_list_since_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = ReadingRepository(session)
voulue = await creer_lecture(session, site_id=premier.site_id)
await creer_lecture(session, site_id=second.site_id)
resultats = await depot.list_since(
since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [voulue.reading_id]
async def test_list_history_orders_the_readings_by_timestamp_descending(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
ancienne = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)
)
recente = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
)
resultats = await depot.list_history(
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=100,
offset=0,
)
identifiants = [
r.reading_id for r in resultats if r.reading_id in (ancienne.reading_id, recente.reading_id)
]
await session.rollback()
assert identifiants == [recente.reading_id, ancienne.reading_id]
async def test_list_history_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = ReadingRepository(session)
voulue = await creer_lecture(session, site_id=premier.site_id)
await creer_lecture(session, site_id=second.site_id)
resultats = await depot.list_history(
site_id=premier.site_id,
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=100,
offset=0,
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [voulue.reading_id]
async def test_list_history_excludes_readings_outside_the_window(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
dedans = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 10, tzinfo=UTC)
)
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 8, 1, tzinfo=UTC))
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 10, 1, tzinfo=UTC))
resultats = await depot.list_history(
site_id=site.site_id,
start=datetime(2026, 9, 1, tzinfo=UTC),
end=datetime(2026, 9, 30, tzinfo=UTC),
limit=100,
offset=0,
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [dedans.reading_id]
async def test_list_history_respects_limit_and_offset(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
lectures = [
await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, jour, tzinfo=UTC)
)
for jour in (1, 2, 3)
]
resultats = await depot.list_history(
site_id=site.site_id,
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=1,
offset=1,
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [lectures[1].reading_id]
async def test_list_history_returns_an_empty_list_when_there_is_nothing(
session: AsyncSession,
) -> None:
depot = ReadingRepository(session)
resultats = await depot.list_history(
site_id=identifiant_site(),
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=100,
offset=0,
)
assert list(resultats) == []
@@ -1,142 +0,0 @@
import uuid
from datetime import UTC, datetime
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert, Recommendation, Site
from app.repositories import recommendation as module_recommendation
from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository
pytestmark = pytest.mark.integration
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
async def creer_site(session: AsyncSession) -> str:
site_id = f"TEST-{uuid.uuid4()}"
session.add(Site(site_id=site_id, site_name="Site de test", site_type="office"))
await session.flush()
return site_id
async def creer_alerte(session: AsyncSession) -> int:
site_id = await creer_site(session)
alerte = Alert(
source_alert_id=str(uuid.uuid4()),
site_id=site_id,
source="api_mock",
timestamp=MOMENT,
type="spike",
severity="high",
message="Test",
raw_data={},
)
session.add(alerte)
await session.flush()
return alerte.alert_id
async def creer(session: AsyncSession, **overrides: object) -> Recommendation:
recommendation = Recommendation(
alert_id=overrides.get("alert_id") or await creer_alerte(session),
action=overrides.get("action", "Vérifier la consommation"),
explanation=overrides.get("explanation", "Pic détecté"),
rule_reference=overrides.get("rule_reference", f"spike-{uuid.uuid4().hex[:8]}"),
)
session.add(recommendation)
await session.flush()
return recommendation
async def test_get_by_id_returns_the_matching_recommendation(session: AsyncSession) -> None:
depot = RecommendationRepository(session)
cree = await creer(session)
trouve = await depot.get_by_id(cree.recommendation_id)
action = trouve.action if trouve else None
await session.rollback()
assert action == "Vérifier la consommation"
async def test_get_by_id_returns_nothing_for_an_unknown_identifier(
session: AsyncSession,
) -> None:
trouve = await RecommendationRepository(session).get_by_id(0)
assert trouve is None
async def test_list_all_returns_the_recommendations_sorted_by_identifier(
session: AsyncSession,
) -> None:
depot = RecommendationRepository(session)
premiere = await creer(session)
seconde = await creer(session)
recommendations = await depot.list_all()
identifiants = [
r.recommendation_id
for r in recommendations
if r.recommendation_id in (premiere.recommendation_id, seconde.recommendation_id)
]
await session.rollback()
assert identifiants == sorted(identifiants)
def nouvelle(alert_id: int, reference: str = "spike-delestage-v1") -> NouvelleRecommandation:
return NouvelleRecommandation(
alert_id=alert_id,
action="Délester les équipements non prioritaires",
explanation="Pic de consommation signalé.",
rule_reference=reference,
)
async def test_create_missing_inserts_the_proposals(session: AsyncSession) -> None:
depot = RecommendationRepository(session)
alert_id = await creer_alerte(session)
creees = await depot.create_missing(
[nouvelle(alert_id), nouvelle(alert_id, "escalade-astreinte-v1")]
)
await session.rollback()
assert creees == 2
async def test_create_missing_ignores_a_rule_already_held_for_the_alert(
session: AsyncSession,
) -> None:
depot = RecommendationRepository(session)
alert_id = await creer_alerte(session)
await depot.create_missing([nouvelle(alert_id)])
creees = await depot.create_missing([nouvelle(alert_id)])
await session.rollback()
assert creees == 0
async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSession) -> None:
creees = await RecommendationRepository(session).create_missing([])
assert creees == 0
async def test_create_missing_inserts_every_proposal_across_several_batches(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(module_recommendation, "TAILLE_DE_LOT", 2)
depot = RecommendationRepository(session)
alert_id = await creer_alerte(session)
propositions = [nouvelle(alert_id, f"regle-{index}-v1") for index in range(5)]
creees = await depot.create_missing(propositions)
enregistrees = [r for r in await depot.list_all() if r.alert_id == alert_id]
await session.rollback()
assert creees == 5
assert len(enregistrees) == 5
@@ -178,16 +178,12 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
user_agent=None,
)
famille = uuid.uuid4()
empreinte = fingerprint_refresh(secret)
expiration = datetime.now(UTC) + DUREE
with pytest.raises(IntegrityError):
await depot.create(
user_id=compte,
family_id=famille,
token_hash=empreinte,
expires_at=expiration,
family_id=uuid.uuid4(),
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + DUREE,
client_ip=None,
user_agent=None,
)
+7 -5
View File
@@ -31,12 +31,14 @@ async def test_the_database_refuses_an_email_written_in_upper_case(
) -> None:
saisie = adresse().upper()
requete = text(
"insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
)
with pytest.raises(IntegrityError):
await session.execute(requete, {"e": saisie})
await session.execute(
text(
"insert into app_user (email, password_hash, role) "
"values (:e, '$argon2id$x', 'lecteur')"
),
{"e": saisie},
)
await session.rollback()
-61
View File
@@ -1,61 +0,0 @@
import pytest
from pydantic import ValidationError
from app.schemas.auth import PasswordChangeRequest, valide_complexite
MOT_DE_PASSE_VALIDE = "Un-mot-de-passe1!"
def test_password_change_request_accepts_a_password_covering_the_four_classes() -> None:
requete = PasswordChangeRequest(
current_password="peu-importe", new_password=MOT_DE_PASSE_VALIDE
)
assert requete.new_password == MOT_DE_PASSE_VALIDE
@pytest.mark.parametrize(
"new_password",
[
"un-mot-de-passe1!",
"UN-MOT-DE-PASSE1!",
"Un-mot-de-passe!",
"Un mot de passe 1",
],
ids=["sans_majuscule", "sans_minuscule", "sans_chiffre", "sans_caractere_special"],
)
def test_password_change_request_rejects_a_password_missing_a_character_class(
new_password: str,
) -> None:
with pytest.raises(ValidationError):
PasswordChangeRequest(current_password="peu-importe", new_password=new_password)
def test_password_change_request_rejects_a_password_below_the_minimum_length() -> None:
with pytest.raises(ValidationError):
PasswordChangeRequest(current_password="peu-importe", new_password="Ab1!")
def test_valide_complexite_names_every_missing_class_in_the_error() -> None:
with pytest.raises(ValueError, match=r"majuscule.*chiffre|chiffre.*majuscule"):
valide_complexite("minuscules-seulement")
def test_valide_complexite_accepts_an_accented_password() -> None:
assert valide_complexite("Sécurité1!") == "Sécurité1!"
@pytest.mark.parametrize("mot_de_passe", ["abcdefg1×", "abcdefg1÷"]) # noqa: RUF001
def test_valide_complexite_rejects_a_password_without_uppercase_despite_times_or_divide(
mot_de_passe: str,
) -> None:
with pytest.raises(ValueError, match="majuscule"):
valide_complexite(mot_de_passe)
@pytest.mark.parametrize("mot_de_passe", ["ABCDEFG1×", "ABCDEFG1÷"]) # noqa: RUF001
def test_valide_complexite_rejects_a_password_without_lowercase_despite_times_or_divide(
mot_de_passe: str,
) -> None:
with pytest.raises(ValueError, match="minuscule"):
valide_complexite(mot_de_passe)
-457
View File
@@ -1,457 +0,0 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from app.models.energy import Alert
from app.services.alert import OUTAGE_THRESHOLD, AlertService, _severity_from_ratio
NOW = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def alert(
alert_id: int = 1,
site_id: str = "site-1",
severity: str = "high",
) -> Alert:
return Alert(
alert_id=alert_id,
source_alert_id=f"ALR-{alert_id}",
site_id=site_id,
source="enervision",
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
type="threshold",
severity=severity,
message="Dépassement du seuil configuré",
value=812.5,
threshold=720.0,
metric="consumption_kw",
prediction_id=None,
raw_data={},
)
@dataclass
class FauxSite:
site_id: str
capacity_kw: float | None = None
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime
consumption_kw: float | None = None
consumption_kwh: float | None = None
data_quality: str | None = None
null_reasons: list[str] | None = None
@dataclass
class FauxPrediction:
site_id: str
target_at: datetime
predicted_value: float | None
target_metric: str = "consumption_kwh"
prediction_id: int = 1
class FakeRepository:
def __init__(self, alerts: list[Alert]) -> None:
self._alerts = alerts
self.appels: list[tuple[str | None, str | None]] = []
self.crees: list[Alert] = []
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> list[Alert]:
self.appels.append((site_id, severity))
return self._alerts
async def create_many(self, alerts: list[Alert]) -> list[Alert]:
self.crees = list(alerts)
return self.crees
@dataclass
class FauxDepotLectures:
depuis: list[FauxLecture] = field(default_factory=list)
dernieres: list[FauxLecture] = field(default_factory=list)
async def list_since(self, *, since: datetime, site_id: str | None = None) -> list[FauxLecture]:
return [lecture for lecture in self.depuis if site_id is None or lecture.site_id == site_id]
async def latest_by_site(self) -> list[FauxLecture]:
return self.dernieres
@dataclass
class FauxDepotPredictions:
predictions: list[FauxPrediction] = field(default_factory=list)
async def list_since(
self, *, since: datetime, site_id: str | None = None
) -> list[FauxPrediction]:
return [p for p in self.predictions if site_id is None or p.site_id == site_id]
@dataclass
class FauxDepotSites:
sites: list[FauxSite]
async def list_all(self) -> list[FauxSite]:
return self.sites
def service(
*,
sites: list[FauxSite],
lectures: list[FauxLecture] | None = None,
dernieres: list[FauxLecture] | None = None,
predictions: list[FauxPrediction] | None = None,
alerts: FakeRepository | None = None,
) -> tuple[AlertService, FakeRepository]:
depot_alertes = alerts or FakeRepository([])
dernieres_lectures = dernieres if dernieres is not None else (lectures or [])
return (
AlertService(
alerts=depot_alertes, # type: ignore[arg-type]
readings=FauxDepotLectures(depuis=lectures or [], dernieres=dernieres_lectures), # type: ignore[arg-type]
predictions=FauxDepotPredictions(predictions or []), # type: ignore[arg-type]
sites=FauxDepotSites(sites), # type: ignore[arg-type]
),
depot_alertes,
)
async def test_list_all_returns_the_repository_alerts() -> None:
svc, _ = service(sites=[], alerts=FakeRepository([alert(1), alert(2)]))
alertes = await svc.list_all()
assert [a.alert_id for a in alertes] == [1, 2]
async def test_list_all_relays_the_filters_to_the_repository() -> None:
depot = FakeRepository([])
svc, _ = service(sites=[], alerts=depot)
await svc.list_all(site_id="site-1", severity="critical")
assert depot.appels == [("site-1", "critical")]
async def test_detect_raises_a_threshold_alert_above_site_capacity() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0)],
lectures=[FauxLecture("A", NOW, consumption_kw=150.0)],
)
await svc.detect(now=NOW)
(candidate,) = depot.crees
assert candidate.type == "threshold"
assert candidate.severity == "high"
assert candidate.value == 150.0
assert candidate.threshold == 100.0
assert candidate.metric == "consumption_kw"
async def test_detect_ignores_a_reading_within_capacity() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0)],
lectures=[FauxLecture("A", NOW, consumption_kw=80.0)],
)
await svc.detect(now=NOW)
assert depot.crees == []
async def test_detect_ignores_threshold_when_the_site_has_no_declared_capacity() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=None)],
lectures=[FauxLecture("A", NOW, consumption_kw=9999.0)],
)
await svc.detect(now=NOW)
assert depot.crees == []
async def test_detect_raises_a_spike_alert_on_a_brutal_consecutive_variation() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=100.0),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "spike"]
assert candidate.value == 160.0
assert candidate.threshold == 100.0
assert candidate.timestamp == NOW
async def test_detect_ignores_a_moderate_consecutive_variation() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=100.0),
FauxLecture("A", NOW, consumption_kw=110.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_never_compares_consecutive_readings_across_two_sites() -> None:
svc, depot = service(
sites=[FauxSite("A"), FauxSite("B")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=10.0),
FauxLecture("B", NOW, consumption_kw=1000.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_raises_an_anomaly_alert_far_from_the_matching_prediction() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[FauxPrediction("A", target_at=NOW, predicted_value=70.0)],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "anomaly"]
assert candidate.value == 100.0
assert candidate.threshold == 70.0
assert candidate.metric == "consumption_kwh"
assert candidate.prediction_id == 1
async def test_detect_ignores_a_reading_close_to_its_prediction() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[FauxPrediction("A", target_at=NOW, predicted_value=95.0)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "anomaly"] == []
async def test_detect_ignores_a_prediction_whose_target_at_does_not_match_the_reading() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[FauxPrediction("A", target_at=NOW - timedelta(hours=1), predicted_value=1.0)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "anomaly"] == []
async def test_detect_keeps_the_most_recent_run_when_two_predictions_share_the_same_target() -> (
None
):
# `PredictionRepository.list_since` départage les égalités de `target_at` par `prediction_id`
# croissant : le repository fait donc déjà passer le run le plus récent en dernier dans la
# liste, et c'est ce dernier que le dict de `_detect_anomaly` doit retenir.
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[
FauxPrediction("A", target_at=NOW, predicted_value=100.0, prediction_id=1),
FauxPrediction("A", target_at=NOW, predicted_value=70.0, prediction_id=2),
],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "anomaly"]
assert candidate.threshold == 70.0
assert candidate.prediction_id == 2
async def test_detect_raises_an_outage_alert_past_the_threshold() -> None:
derniere = NOW - OUTAGE_THRESHOLD - timedelta(minutes=1)
svc, depot = service(
sites=[FauxSite("A")],
lectures=[],
dernieres=[FauxLecture("A", derniere)],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "outage"]
assert candidate.severity in {"low", "medium", "high", "critical"}
async def test_detect_ignores_a_site_still_within_the_outage_threshold() -> None:
derniere = NOW - OUTAGE_THRESHOLD + timedelta(minutes=1)
svc, depot = service(
sites=[FauxSite("A")],
lectures=[],
dernieres=[FauxLecture("A", derniere)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "outage"] == []
async def test_detect_raises_a_critical_outage_alert_for_a_site_never_read() -> None:
svc, depot = service(sites=[FauxSite("A")], lectures=[], dernieres=[])
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "outage"]
assert candidate.severity == "critical"
assert candidate.source_alert_id == "outage:jamais"
async def test_detect_raises_a_sensor_alert_on_a_degraded_reading() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, data_quality="critical", null_reasons=["missing:x"])],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "sensor"]
assert candidate.severity == "critical"
async def test_detect_ignores_a_good_quality_reading_for_the_sensor_rule() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, data_quality="good")],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "sensor"] == []
async def test_detect_scopes_to_a_single_site_when_asked() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0), FauxSite("B", capacity_kw=100.0)],
lectures=[
FauxLecture("A", NOW, consumption_kw=150.0),
FauxLecture("B", NOW, consumption_kw=150.0),
],
)
await svc.detect(now=NOW, site_id="A")
assert {a.site_id for a in depot.crees} == {"A"}
async def test_detect_returns_early_when_there_is_no_site() -> None:
svc, depot = service(sites=[])
resultat = await svc.detect(now=NOW)
assert resultat == []
assert depot.crees == []
async def test_detect_ignores_a_spike_pair_with_a_missing_measurement() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=None),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_ignores_a_reading_still_at_zero_after_a_previous_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0),
FauxLecture("A", NOW, consumption_kw=0.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_raises_a_critical_spike_when_a_site_restarts_from_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0),
FauxLecture("A", NOW, consumption_kw=50.0),
],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "spike"]
assert candidate.severity == "critical"
assert candidate.value == 50.0
assert candidate.threshold == 0.0
async def test_detect_ignores_a_spike_pair_sharing_the_same_timestamp() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW, consumption_kw=100.0),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_ignores_an_anomaly_when_the_prediction_is_near_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=5.0)],
predictions=[FauxPrediction("A", target_at=NOW, predicted_value=0.0)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "anomaly"] == []
def test_severity_from_ratio_covers_every_band() -> None:
assert _severity_from_ratio(1.0) == "low"
assert _severity_from_ratio(1.2) == "medium"
assert _severity_from_ratio(1.5) == "high"
assert _severity_from_ratio(2.0) == "critical"
async def test_detect_does_not_call_create_many_when_nothing_triggers() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0)],
lectures=[FauxLecture("A", NOW, consumption_kw=10.0, data_quality="good")],
)
resultat = await svc.detect(now=NOW)
assert resultat == []
assert depot.crees == []
+1 -223
View File
@@ -5,7 +5,6 @@ from typing import Any
from uuid import UUID, uuid4
import pytest
from fastapi import BackgroundTasks
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
@@ -17,15 +16,11 @@ from app.core.security import (
from app.models.login_attempt import LoginOutcome
from app.models.refresh_token import RevocationReason
from app.repositories.login_attempt import FailureCounts
from app.repositories.password_reset_attempt import ResetRequestCounts
from app.repositories.password_reset_token import ConsumedResetToken
from app.repositories.refresh_token import ClaimedToken
from app.services.auth import (
AuthService,
InvalidCredentialsError,
InvalidOrExpiredResetTokenError,
LoginPolicy,
PasswordResetPolicy,
RateLimitedError,
SessionRejectedError,
)
@@ -42,13 +37,6 @@ POLITIQUE_CONNEXION = LoginPolicy(
max_failures_per_ip=20,
max_failures_per_identifier=50,
)
POLITIQUE_RESET = PasswordResetPolicy(
window_seconds=900,
max_requests_per_identifier=3,
max_requests_per_ip=10,
token_ttl=timedelta(minutes=15),
frontend_reset_url="http://localhost:4200/reset-password",
)
@dataclass
@@ -180,49 +168,6 @@ class FausseTransaction:
self.validations += 1
class FauxDepotJetonsReset:
def __init__(
self, revendique: ConsumedResetToken | None = None, *, valide: bool = False
) -> None:
self.revendique = revendique
self.valide = valide
self.crees: list[UUID] = []
self.invalidations: list[UUID] = []
async def create(self, *, user_id: UUID, **_: object) -> None:
self.crees.append(user_id)
async def consume(self, token_hash: bytes) -> ConsumedResetToken | None:
return self.revendique
async def exists_valid(self, token_hash: bytes) -> bool:
return self.valide
async def invalidate_all_for_user(self, user_id: UUID) -> int:
self.invalidations.append(user_id)
return len(self.invalidations)
class FauxDepotTentativesReset:
def __init__(self, compteurs: ResetRequestCounts | None = None) -> None:
self.compteurs = compteurs or ResetRequestCounts(0, 0)
self.enregistrees: list[str] = []
async def count_recent(self, **_: object) -> ResetRequestCounts:
return self.compteurs
async def record(self, *, email: str, **_: object) -> None:
self.enregistrees.append(email)
class FauxMailer:
def __init__(self) -> None:
self.envois: list[tuple[str, str]] = []
async def send_password_reset_email(self, *, to: str, reset_url: str) -> None:
self.envois.append((to, reset_url))
@dataclass
class Attirail:
service: AuthService
@@ -231,9 +176,6 @@ class Attirail:
jetons: FauxDepotJetons
audit: FauxDepotAudit
hacheur: FauxHacheur
jetons_reset: FauxDepotJetonsReset
tentatives_reset: FauxDepotTentativesReset
mailer: FauxMailer
def fabrique_service(
@@ -242,17 +184,12 @@ def fabrique_service(
compteurs: FailureCounts | None = None,
hacheur: FauxHacheur | None = None,
jetons: FauxDepotJetons | None = None,
jetons_reset: FauxDepotJetonsReset | None = None,
compteurs_reset: ResetRequestCounts | None = None,
) -> Attirail:
comptes = FauxDepotComptes(compte)
tentatives = FauxDepotTentatives(compteurs)
depot_jetons = jetons or FauxDepotJetons()
audit = FauxDepotAudit()
hacheur = hacheur or FauxHacheur()
depot_jetons_reset = jetons_reset or FauxDepotJetonsReset()
tentatives_reset = FauxDepotTentativesReset(compteurs_reset)
mailer = FauxMailer()
service = AuthService(
users=comptes, # type: ignore[arg-type]
attempts=tentatives, # type: ignore[arg-type]
@@ -263,22 +200,8 @@ def fabrique_service(
token_policy=POLITIQUE_JETON,
login_policy=POLITIQUE_CONNEXION,
refresh_ttl=timedelta(days=7),
reset_tokens=depot_jetons_reset, # type: ignore[arg-type]
reset_attempts=tentatives_reset, # type: ignore[arg-type]
reset_policy=POLITIQUE_RESET,
mailer=mailer, # type: ignore[arg-type]
)
return Attirail(
service,
comptes,
tentatives,
depot_jetons,
audit,
hacheur,
depot_jetons_reset,
tentatives_reset,
mailer,
)
return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur)
async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object:
@@ -570,148 +493,3 @@ async def test_change_password_refuses_a_wrong_current_password() -> None:
assert attirail.jetons.revocations_par_compte == []
assert attirail.jetons.crees == []
async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None:
compte = FauxCompte()
attirail = fabrique_service(compte=compte)
taches = BackgroundTasks()
await attirail.service.request_password_reset(
email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches
)
assert attirail.jetons_reset.invalidations == [compte.id]
assert attirail.jetons_reset.crees == [compte.id]
assert attirail.mailer.envois == [], "l'envoi doit être différé, pas fait dans la réponse"
await taches()
assert len(attirail.mailer.envois) == 1
assert attirail.mailer.envois[0][0] == compte.email
assert "auth.password_reset_requested" in attirail.audit.lignes[0][0]
async def test_request_password_reset_stays_silent_when_the_account_is_unknown() -> None:
attirail = fabrique_service(compte=None)
taches = BackgroundTasks()
await attirail.service.request_password_reset(
email="inconnu@enervision.fr",
client_ip="203.0.113.10",
user_agent="pytest",
background_tasks=taches,
)
await taches()
assert attirail.jetons_reset.crees == []
assert attirail.mailer.envois == []
assert attirail.hacheur.verifications == 1, "le hachage factice doit tout de même tourner"
async def test_request_password_reset_stays_silent_when_the_account_is_inactive() -> None:
compte = FauxCompte(is_active=False)
attirail = fabrique_service(compte=compte)
taches = BackgroundTasks()
await attirail.service.request_password_reset(
email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches
)
await taches()
assert attirail.jetons_reset.crees == []
assert attirail.mailer.envois == []
async def test_request_password_reset_raises_when_the_rate_limit_is_reached() -> None:
attirail = fabrique_service(compteurs_reset=ResetRequestCounts(per_identifier=3, per_ip=0))
taches = BackgroundTasks()
with pytest.raises(RateLimitedError):
await attirail.service.request_password_reset(
email="operateur@enervision.fr",
client_ip="203.0.113.10",
user_agent="pytest",
background_tasks=taches,
)
await taches()
assert attirail.mailer.envois == []
async def test_request_password_reset_logs_instead_of_raising_when_the_mailer_fails() -> None:
compte = FauxCompte()
attirail = fabrique_service(compte=compte)
taches = BackgroundTasks()
async def echoue(*, to: str, reset_url: str) -> None:
raise RuntimeError("relais SMTP indisponible")
attirail.mailer.send_password_reset_email = echoue # type: ignore[method-assign]
await attirail.service.request_password_reset(
email=compte.email, client_ip="203.0.113.10", user_agent="pytest", background_tasks=taches
)
await taches()
async def test_confirm_password_reset_revokes_every_session_then_reopens_the_current_one() -> None:
compte = FauxCompte()
jetons_reset = FauxDepotJetonsReset(
revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id)
)
attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset)
session = await attirail.service.confirm_password_reset(
token="un-secret-opaque",
new_password="Un-nouveau-mot-de-passe1!",
client_ip="203.0.113.10",
user_agent="pytest",
)
assert attirail.jetons.revocations_par_compte == [
(compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value)
]
assert len(attirail.jetons.crees) == 1
assert session.refresh_secret
assert "auth.password_reset_self_service" in attirail.audit.lignes[0][0]
async def test_is_reset_token_valid_reflects_the_repository() -> None:
attirail_valide = fabrique_service(jetons_reset=FauxDepotJetonsReset(valide=True))
attirail_invalide = fabrique_service(jetons_reset=FauxDepotJetonsReset(valide=False))
assert await attirail_valide.service.is_reset_token_valid("un-secret-opaque") is True
assert await attirail_invalide.service.is_reset_token_valid("un-secret-opaque") is False
async def test_confirm_password_reset_rejects_a_token_for_an_account_disabled_since() -> None:
compte = FauxCompte(is_active=False)
jetons_reset = FauxDepotJetonsReset(
revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id)
)
attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset)
with pytest.raises(InvalidOrExpiredResetTokenError):
await attirail.service.confirm_password_reset(
token="un-secret-opaque",
new_password="Un-nouveau-mot-de-passe1!",
client_ip="203.0.113.10",
user_agent="pytest",
)
assert attirail.comptes.mots_de_passe_changes == 0
assert attirail.jetons.revocations_par_compte == []
async def test_confirm_password_reset_rejects_an_invalid_or_expired_token() -> None:
attirail = fabrique_service(jetons_reset=FauxDepotJetonsReset(revendique=None))
with pytest.raises(InvalidOrExpiredResetTokenError):
await attirail.service.confirm_password_reset(
token="un-secret-invalide",
new_password="Un-nouveau-mot-de-passe1!",
client_ip=None,
user_agent=None,
)
assert attirail.jetons.revocations_par_compte == []
@@ -1,121 +0,0 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from app.services.prediction import PredictionService
TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC)
CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
@dataclass
class FauxSite:
site_id: str
site_name: str
@dataclass
class FauxPrediction:
site_id: str
target_at: datetime
target_metric: str
period_minutes: int | None
predicted_value: float | None
status: str
failure_reason: str | None
model_reference: str
created_at: datetime
class FauxDepotSites:
def __init__(self, sites: list[FauxSite]) -> None:
self._sites = sites
async def list_all(self) -> list[FauxSite]:
return self._sites
class FauxDepotPredictions:
def __init__(self, predictions: list[FauxPrediction]) -> None:
self._predictions = predictions
async def latest_by_site(self) -> list[FauxPrediction]:
return self._predictions
def prediction_disponible(site_id: str = "A") -> FauxPrediction:
return FauxPrediction(
site_id=site_id,
target_at=TARGET_AT,
target_metric="consumption_kwh",
period_minutes=60,
predicted_value=812.5,
status="available",
failure_reason=None,
model_reference="lightgbm-abc123",
created_at=CREATED_AT,
)
async def test_summary_attaches_the_latest_prediction_to_its_site() -> None:
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type]
)
resume = await service.summary()
site = resume.sites[0]
assert site.site_id == "A"
assert site.prediction is not None
assert site.prediction.predicted_value == 812.5
assert site.prediction.status == "available"
async def test_summary_leaves_prediction_none_for_a_site_never_scored() -> None:
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([]), # type: ignore[arg-type]
)
resume = await service.summary()
assert resume.sites[0].prediction is None
async def test_summary_carries_an_insufficient_data_prediction_without_a_value() -> None:
insuffisante = FauxPrediction(
site_id="A",
target_at=TARGET_AT,
target_metric="consumption_kwh",
period_minutes=60,
predicted_value=None,
status="insufficient_data",
failure_reason="pas assez d'historique",
model_reference="lightgbm-abc123",
created_at=CREATED_AT,
)
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([insuffisante]), # type: ignore[arg-type]
)
resume = await service.summary()
site = resume.sites[0]
assert site.prediction is not None
assert site.prediction.status == "insufficient_data"
assert site.prediction.predicted_value is None
assert site.prediction.failure_reason == "pas assez d'historique"
async def test_summary_covers_every_site_even_with_a_single_prediction_in_the_repository() -> None:
service = PredictionService(
sites=FauxDepotSites([FauxSite("A", "Site A"), FauxSite("B", "Site B")]), # type: ignore[arg-type]
predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type]
)
resume = await service.summary()
par_site = {site.site_id: site for site in resume.sites}
assert par_site["A"].prediction is not None
assert par_site["B"].prediction is None
-151
View File
@@ -1,151 +0,0 @@
from datetime import UTC, datetime, timedelta
import pytest
from app.models.energy import Reading
from app.services.reading import (
FENETRE_MAXIMALE,
FENETRE_PAR_DEFAUT,
FenetreInverseeError,
FenetreTropLargeError,
ReadingService,
)
def reading(reading_id: int = 1, site_id: str = "site-1") -> Reading:
return Reading(
reading_id=reading_id,
site_id=site_id,
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
source="api_current",
consumption_kw=10.0,
data_quality="good",
raw_data={},
)
class FakeRepository:
def __init__(self, readings: list[Reading]) -> None:
self._readings = readings
self.appels: list[tuple[str | None, datetime, datetime, int, int]] = []
async def list_history(
self,
*,
start: datetime,
end: datetime,
site_id: str | None = None,
limit: int,
offset: int,
) -> list[Reading]:
self.appels.append((site_id, start, end, limit, offset))
return self._readings
async def test_list_history_returns_the_repository_readings() -> None:
service = ReadingService(readings=FakeRepository([reading(1), reading(2)]))
lectures = await service.list_history(limit=500, offset=0)
assert [r.reading_id for r in lectures] == [1, 2]
async def test_list_history_relays_the_site_id_limit_and_offset() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
debut = datetime(2026, 9, 1, tzinfo=UTC)
fin = datetime(2026, 9, 2, tzinfo=UTC)
await service.list_history(site_id="site-1", start=debut, end=fin, limit=50, offset=10)
assert depot.appels == [("site-1", debut, fin, 50, 10)]
async def test_list_history_defaults_to_the_last_24_hours_when_no_window_is_given() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
avant = datetime.now(UTC)
await service.list_history(limit=500, offset=0)
apres = datetime.now(UTC)
_, debut, fin, _, _ = depot.appels[0]
assert avant <= fin <= apres
assert fin - debut == FENETRE_PAR_DEFAUT
async def test_list_history_defaults_end_to_now_when_only_start_is_given() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
debut = datetime.now(UTC) - timedelta(hours=1)
avant = datetime.now(UTC)
await service.list_history(start=debut, limit=500, offset=0)
apres = datetime.now(UTC)
_, debut_transmis, fin, _, _ = depot.appels[0]
assert debut_transmis == debut
assert avant <= fin <= apres
async def test_list_history_defaults_start_to_24_hours_before_end_when_only_end_is_given() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
fin = datetime(2026, 9, 16, tzinfo=UTC)
await service.list_history(end=fin, limit=500, offset=0)
_, debut, fin_transmise, _, _ = depot.appels[0]
assert fin_transmise == fin
assert debut == fin - FENETRE_PAR_DEFAUT
async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
await service.list_history(
start=datetime(2026, 9, 1), end=datetime(2026, 9, 2), limit=500, offset=0
)
_, debut, fin, _, _ = depot.appels[0]
assert debut == datetime(2026, 9, 1, tzinfo=UTC)
assert fin == datetime(2026, 9, 2, tzinfo=UTC)
async def test_list_history_raises_when_start_is_after_end() -> None:
service = ReadingService(readings=FakeRepository([]))
debut = datetime(2026, 9, 2, tzinfo=UTC)
fin = datetime(2026, 9, 1, tzinfo=UTC)
with pytest.raises(FenetreInverseeError):
await service.list_history(start=debut, end=fin, limit=500, offset=0)
async def test_list_history_raises_when_start_equals_end() -> None:
service = ReadingService(readings=FakeRepository([]))
instant = datetime(2026, 9, 1, tzinfo=UTC)
with pytest.raises(FenetreInverseeError):
await service.list_history(start=instant, end=instant, limit=500, offset=0)
async def test_list_history_raises_when_the_window_exceeds_the_maximum_span() -> None:
service = ReadingService(readings=FakeRepository([]))
debut = datetime(2026, 1, 1, tzinfo=UTC)
fin = debut + FENETRE_MAXIMALE + timedelta(seconds=1)
with pytest.raises(FenetreTropLargeError):
await service.list_history(start=debut, end=fin, limit=500, offset=0)
async def test_list_history_accepts_a_window_exactly_at_the_maximum_span() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
debut = datetime(2026, 1, 1, tzinfo=UTC)
fin = debut + FENETRE_MAXIMALE
await service.list_history(start=debut, end=fin, limit=500, offset=0)
assert depot.appels == [(None, debut, fin, 500, 0)]
@@ -1,163 +0,0 @@
from collections.abc import Sequence
from datetime import UTC, datetime
import pytest
from app.models.energy import Alert, Recommendation
from app.repositories.recommendation import NouvelleRecommandation
from app.services.recommendation import RecommendationNotFoundError, RecommendationService
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
def recommendation(recommendation_id: int = 1) -> Recommendation:
return Recommendation(
recommendation_id=recommendation_id,
alert_id=1,
action="Vérifier la consommation",
explanation="Pic détecté",
rule_reference="spike-v1",
created_at=MOMENT,
)
def alerte(alert_id: int = 1, site_id: str = "SITE001", severity: str = "high") -> Alert:
return Alert(
alert_id=alert_id,
source_alert_id=f"ALR-{alert_id}",
site_id=site_id,
source="api_mock",
timestamp=MOMENT,
type="spike",
severity=severity,
message="Pic de consommation",
value=None,
threshold=None,
metric=None,
prediction_id=None,
raw_data={},
)
class FakeRepository:
def __init__(self, recommendations: list[Recommendation], creees: int | None = None) -> None:
self._recommendations = recommendations
self._creees = creees
self.recues: list[NouvelleRecommandation] = []
async def list_all(self) -> list[Recommendation]:
return self._recommendations
async def get_by_id(self, recommendation_id: int) -> Recommendation | None:
return next(
(r for r in self._recommendations if r.recommendation_id == recommendation_id), None
)
async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int:
self.recues = list(nouvelles)
return len(self.recues) if self._creees is None else self._creees
class FakeAlertRepository:
def __init__(self, alertes: list[Alert]) -> None:
self._alertes = alertes
self.site_demande: str | None = None
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> list[Alert]:
self.site_demande = site_id
if site_id is None:
return self._alertes
return [a for a in self._alertes if a.site_id == site_id]
class FakeTransaction:
def __init__(self) -> None:
self.commits = 0
async def commit(self) -> None:
self.commits += 1
def service(
recommendations: FakeRepository | None = None,
alerts: FakeAlertRepository | None = None,
transaction: FakeTransaction | None = None,
) -> RecommendationService:
return RecommendationService(
recommendations=recommendations or FakeRepository([]),
alerts=alerts or FakeAlertRepository([]),
transaction=transaction or FakeTransaction(),
)
async def test_list_all_returns_the_repository_recommendations() -> None:
depot = FakeRepository([recommendation(1), recommendation(2)])
recommendations = await service(recommendations=depot).list_all()
assert [r.recommendation_id for r in recommendations] == [1, 2]
async def test_get_by_id_returns_the_matching_recommendation() -> None:
trouve = await service(recommendations=FakeRepository([recommendation(1)])).get_by_id(1)
assert trouve.recommendation_id == 1
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
with pytest.raises(RecommendationNotFoundError):
await service().get_by_id(404)
async def test_generate_persists_one_proposal_per_triggered_rule() -> None:
depot = FakeRepository([])
rapport = await service(
recommendations=depot, alerts=FakeAlertRepository([alerte(severity="critical")])
).generate()
assert {n.rule_reference for n in depot.recues} == {
"spike-delestage-v1",
"escalade-astreinte-v1",
}
assert rapport.recommandations_creees == 2
async def test_generate_commits_once() -> None:
transaction = FakeTransaction()
await service(alerts=FakeAlertRepository([alerte()]), transaction=transaction).generate()
assert transaction.commits == 1
async def test_generate_restricts_the_alerts_to_the_requested_site() -> None:
alertes = FakeAlertRepository([alerte(1, site_id="SITE001"), alerte(2, site_id="SITE002")])
depot = FakeRepository([])
rapport = await service(recommendations=depot, alerts=alertes).generate(site_id="SITE002")
assert alertes.site_demande == "SITE002"
assert rapport.alertes_examinees == 1
assert {n.alert_id for n in depot.recues} == {2}
async def test_generate_reports_nothing_when_no_alert_matches() -> None:
rapport = await service().generate()
assert rapport.alertes_examinees == 0
assert rapport.recommandations_creees == 0
assert rapport.deja_presentes == 0
async def test_generate_counts_the_proposals_the_database_already_held() -> None:
depot = FakeRepository([], creees=0)
rapport = await service(
recommendations=depot, alerts=FakeAlertRepository([alerte()])
).generate()
assert rapport.recommandations_creees == 0
assert rapport.deja_presentes == 1
@@ -1,142 +0,0 @@
from datetime import UTC, datetime
import pytest
from app.models.energy import Alert
from app.services.recommendation_rules import FACTEUR_DEPASSEMENT_MAJEUR, applique_les_regles
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
def alerte(
*,
alert_id: int = 1,
type_alerte: str = "spike",
severity: str = "high",
value: float | None = None,
threshold: float | None = None,
metric: str | None = None,
site_id: str = "SITE001",
) -> Alert:
return Alert(
alert_id=alert_id,
source_alert_id=f"ALR-{alert_id}",
site_id=site_id,
source="api_mock",
timestamp=MOMENT,
type=type_alerte,
severity=severity,
message="Alerte de test",
value=value,
threshold=threshold,
metric=metric,
prediction_id=None,
raw_data={},
)
@pytest.mark.parametrize(
("type_alerte", "attendue"),
[
("spike", "spike-delestage-v1"),
("threshold", "threshold-reduction-v1"),
("outage", "outage-secours-v1"),
("sensor", "sensor-maintenance-v1"),
("anomaly", "anomaly-verification-v1"),
],
ids=["pic", "seuil", "coupure", "capteur", "anomalie"],
)
def test_each_alert_type_yields_its_own_rule(type_alerte: str, attendue: str) -> None:
proposees = applique_les_regles(alerte(type_alerte=type_alerte))
assert [p.rule_reference for p in proposees] == [attendue]
def test_a_critical_alert_adds_the_escalation_rule() -> None:
proposees = applique_les_regles(alerte(severity="critical"))
assert "escalade-astreinte-v1" in {p.rule_reference for p in proposees}
@pytest.mark.parametrize("severity", ["low", "medium", "high"], ids=["faible", "moyenne", "haute"])
def test_a_non_critical_alert_does_not_escalate(severity: str) -> None:
proposees = applique_les_regles(alerte(severity=severity))
assert "escalade-astreinte-v1" not in {p.rule_reference for p in proposees}
def test_a_large_overshoot_adds_the_contract_rule() -> None:
proposees = applique_les_regles(
alerte(value=720.0 * FACTEUR_DEPASSEMENT_MAJEUR, threshold=720.0)
)
assert "contrat-puissance-v1" in {p.rule_reference for p in proposees}
def test_an_overshoot_below_the_factor_does_not_add_the_contract_rule() -> None:
proposees = applique_les_regles(alerte(value=800.0, threshold=720.0))
assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees}
@pytest.mark.parametrize(
("value", "threshold"),
[(None, 720.0), (900.0, None), (900.0, 0.0), (900.0, -10.0)],
ids=["sans mesure", "sans seuil", "seuil nul", "seuil negatif"],
)
def test_the_contract_rule_stays_silent_without_an_exploitable_threshold(
value: float | None, threshold: float | None
) -> None:
proposees = applique_les_regles(alerte(value=value, threshold=threshold))
assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees}
def test_the_explanation_quotes_the_measure_and_the_threshold() -> None:
proposees = applique_les_regles(alerte(value=812.5, threshold=720.0, metric="consumption_kw"))
assert "(consumption_kw mesurée à 812.5, seuil 720.0)" in proposees[0].explanation
def test_the_explanation_quotes_the_measure_alone_when_no_threshold_is_known() -> None:
proposees = applique_les_regles(alerte(value=812.5, metric="consumption_kw"))
assert "(consumption_kw mesurée à 812.5)" in proposees[0].explanation
def test_the_explanation_omits_the_measure_when_the_alert_carries_none() -> None:
proposees = applique_les_regles(alerte())
assert "(" not in proposees[0].explanation
def test_the_explanation_names_the_site() -> None:
proposees = applique_les_regles(alerte(site_id="SITE042"))
assert "SITE042" in proposees[0].explanation
def test_every_proposal_carries_the_alert_identifier() -> None:
proposees = applique_les_regles(alerte(alert_id=77, severity="critical"))
assert {p.alert_id for p in proposees} == {77}
def test_an_alert_never_yields_the_same_rule_twice() -> None:
proposees = applique_les_regles(
alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw")
)
assert len(proposees) == len({p.rule_reference for p in proposees})
def test_a_critical_alert_over_the_threshold_yields_the_three_rules() -> None:
proposees = applique_les_regles(
alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw")
)
assert {p.rule_reference for p in proposees} == {
"spike-delestage-v1",
"escalade-astreinte-v1",
"contrat-puissance-v1",
}
-224
View File
@@ -1,224 +0,0 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
from app.services.sensor import SensorService
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
@dataclass
class FauxSite:
site_id: str
site_name: str
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime
data_quality: str | None
null_reasons: list[str] | None = field(default_factory=list)
consumption_kw: float | None = 10.0
voltage_v: float | None = 230.0
current_a: float | None = 5.0
power_factor: float | None = 0.95
temperature_celsius: float | None = 21.0
humidity_percent: float | None = 40.0
class FauxDepotSites:
def __init__(self, sites: list[FauxSite]) -> None:
self._sites = sites
async def list_all(self) -> list[FauxSite]:
return self._sites
class FauxDepotLectures:
def __init__(self, lectures: list[FauxLecture]) -> None:
self._lectures = lectures
async def latest_by_site(self) -> list[FauxLecture]:
return self._lectures
async def test_status_marks_a_site_without_any_reading_as_critical_with_every_sensor_failing() -> (
None
):
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "critical"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "failing"
assert capteur.since is None
async def test_status_marks_every_sensor_ok_on_a_good_quality_reading_with_no_null_field() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "good")]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "ok"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "ok"
assert capteur.since is None
async def test_status_flags_the_sensor_named_in_null_reasons() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[
FauxLecture(
"A",
TIMESTAMP,
"partial",
null_reasons=["temperature_sensor_failure"],
temperature_celsius=None,
)
]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "degraded"
assert site.sensors.temperature.status == "failing"
assert site.sensors.temperature.since == TIMESTAMP
assert site.sensors.consumption.status == "ok"
assert site.sensors.electrical.status == "ok"
assert site.sensors.humidity.status == "ok"
assert site.sensors.network.status == "ok"
async def test_status_flags_a_sensor_from_a_null_field_even_without_a_null_reason() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], humidity_percent=None)]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.sensors.humidity.status == "failing"
assert site.sensors.humidity.since == TIMESTAMP
async def test_status_flags_electrical_as_failing_when_any_of_its_three_fields_is_null() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], power_factor=None)]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.sensors.electrical.status == "failing"
async def test_status_forces_every_sensor_to_failing_when_overall_is_critical() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "critical", null_reasons=[])]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "critical"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "failing"
assert capteur.since == TIMESTAMP
async def test_status_treats_an_unknown_data_quality_as_critical() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, None, null_reasons=[])]), # type: ignore[arg-type]
)
etat = await service.status()
assert etat.sites[0].overall == "critical"
async def test_status_ignores_an_unknown_null_reason() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "good", null_reasons=["something_else"])]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "ok"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "ok"
async def test_status_flags_network_from_null_reasons_only() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[
FauxLecture(
"A",
TIMESTAMP,
"partial",
null_reasons=["network_loss"],
)
]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "degraded"
assert site.sensors.network.status == "failing"
assert site.sensors.network.since == TIMESTAMP
assert site.sensors.consumption.status == "ok"
assert site.sensors.electrical.status == "ok"
assert site.sensors.temperature.status == "ok"
assert site.sensors.humidity.status == "ok"

Some files were not shown because too many files have changed in this diff Show More