Compare commits

..
Author SHA1 Message Date
Johan LEROY af2b8cb768 ci(sonar): déclenche l'analyse quand sonar-project.properties change
SonarQube / build-back (push) Successful in 1m9s
SonarQube / build-front (push) Successful in 9m35s
SonarQube / test-back (push) Failing after 59s
SonarQube / test-front (push) Failing after 5m2s
SonarQube / SonarQube (push) Skipped
Le fichier pilote le périmètre, la classification des tests et les rapports de
couverture, mais il ne figurait pas dans les paths du workflow : un commit qui
ne touchait que lui laissait le quality gate figé sur son verdict précédent.
2026-09-18 14:28:50 +02:00
Johan LEROY 9e6a5c0663 ci(sonar): classe tout apps/backend/tests comme test, pas les seuls test_*.py
Le quality gate de la PR tombait à 0 % de couverture sur le nouveau code :
tests/api/acces.py ne matche ni `**/*test_*.py` ni `**/*test.py`, donc Sonar
le comptait en code de production, qu'aucun rapport de couverture ne mesure.
Le pattern `tests` des exclusions ne l'attrapait pas non plus : sans wildcard,
il ne désigne que le chemin `tests` à la racine du dépôt.

Même défaut pour tests/factories.py et les __init__.py, déjà sur dev.
2026-09-18 14:28:05 +02:00
118 changed files with 563 additions and 10654 deletions
-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=
-6
View File
@@ -38,9 +38,3 @@ updates:
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"
-101
View File
@@ -1,101 +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@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
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@v7
- 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 du module prouve que l'environnement /opt/backend est complet. Les deux
# commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui.
- name: Vérifie que les deux commandes du DAG alertes 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"
+6 -32
View File
@@ -30,10 +30,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -90,10 +90,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -126,10 +126,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -140,29 +140,3 @@ jobs:
# 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@v7
# 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@v7
# 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
+10 -10
View File
@@ -17,10 +17,10 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
@@ -33,10 +33,10 @@ jobs:
name: Audit des dépendances
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
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
@@ -45,10 +45,10 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front)
@@ -58,7 +58,7 @@ jobs:
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
+2 -25
View File
@@ -30,10 +30,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
@@ -57,26 +57,3 @@ jobs:
# 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@v7
# 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@v7
- 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
+18 -56
View File
@@ -5,16 +5,14 @@ on:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml"
- "sonar-project.properties"
pull_request:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml"
- "sonar-project.properties"
# Build l'ensemble du projet, puis lance les tests
@@ -24,10 +22,10 @@ jobs:
build-front:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
@@ -40,10 +38,10 @@ jobs:
needs: build-front
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
@@ -56,7 +54,7 @@ jobs:
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
@@ -64,9 +62,9 @@ jobs:
build-back:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -95,9 +93,9 @@ jobs:
needs: build-back
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -107,65 +105,29 @@ jobs:
working-directory: apps/backend
- name: Upload coverage
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: apps/backend/coverage.xml
test-ml:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
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@v7
with:
name: ml-coverage
path: ml/coverage.xml
sonarqube:
needs: [build-front, build-back, test-front, test-back, test-ml]
needs: [build-front, build-back, test-front, test-back]
name: SonarQube
# Pourquoi : GitHub ne fournit pas les secrets aux workflows lancés par dependabot[bot].
# Sans SONAR_TOKEN le scan échoue sans rien analyser ; build et tests restent joués.
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v8
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@v8
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@v8
with:
name: ml-coverage
path: ml
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
-6
View File
@@ -66,12 +66,6 @@ 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/
+3 -76
View File
@@ -1,31 +1,17 @@
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 \
.PHONY: help install install-backend install-frontend install-ml 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
ml-lint ml-typecheck ml-test ml-check ml-train
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: install-backend install-frontend install-ml ## Installe les dépendances backend, frontend et ML
install-backend: ## Installe les dépendances du backend
cd $(BACKEND) && uv sync --all-groups
@@ -36,9 +22,6 @@ install-frontend: ## Installe les dépendances du frontend
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 & \
@@ -91,65 +74,9 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du
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
+10 -30
View File
@@ -9,10 +9,8 @@ series temporelles energetiques, deployee sur une machine on-premise.
|-------|----------------------------------------------------------|
| J1 | Valider la préparation de l'environnement et du repo |
| J2 | Valider le périmètre retenu et les choix technologiques |
| J3 | Ingestion & backend |
| J4 | Architecture, sécurité & frontend |
| J5 | Valider la robustesse et assurer les livrables |
| J6 | Amélioration possible |
| J3 | Valider l'architecture et la gestion de la sécurité |
| J4 | Valider la robustesse et assurer les livrables |
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
@@ -20,15 +18,14 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Domaine | Technologie | Emplacement | Etat |
|------------|-------------------------------------|---------------------|---------------|
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | En place |
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
| ETL | Apache Airflow | `etl/airflow` | Trois DAGs |
| 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` | A initialiser |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
| ML | LightGBM, MLflow | `ml` | En place |
| 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
@@ -50,13 +47,11 @@ 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)
│ ├── 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/
├── infra/terraform/
│ ├── modules/ Modules reutilisables
│ └── environments/ Racines Terraform, une par environnement
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
@@ -103,21 +98,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.
-4
View File
@@ -18,7 +18,3 @@ 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
-1
View File
@@ -113,7 +113,6 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
| `/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 |
+2 -22
View File
@@ -27,7 +27,6 @@ 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
@@ -35,7 +34,6 @@ 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
@@ -180,23 +178,14 @@ 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),
)
return AlertService(alerts=AlertRepository(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,
)
return RecommendationService(recommendations=RecommendationRepository(session))
RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)]
@@ -223,15 +212,6 @@ def get_sensor_service(session: SessionDep) -> SensorService:
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,
+1 -8
View File
@@ -63,7 +63,7 @@ TAGS: Final[list[dict[str, Any]]] = [
"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`."
"`lecteur`."
),
},
{
@@ -83,13 +83,6 @@ TAGS: Final[list[dict[str, Any]]] = [
"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(
@@ -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,18 +1,13 @@
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.api.deps import LecteurDep, RecommendationServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.recommendation import (
RecommendationGenerationResponse,
RecommendationResponse,
)
from app.schemas.recommendation import 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."},
@@ -43,22 +38,3 @@ async def get_recommendation(
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,
)
-4
View File
@@ -5,7 +5,6 @@ from app.api.v1.endpoints import (
alerts,
auth,
health,
predictions,
readings,
recommendations,
sensors,
@@ -35,6 +34,3 @@ api_router.include_router(
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
)
-31
View File
@@ -22,11 +22,8 @@ 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
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
@@ -66,22 +63,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,14 +109,6 @@ 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
@@ -179,10 +152,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(
-5
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)
@@ -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())
-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()
-34
View File
@@ -1,7 +1,6 @@
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
@@ -20,36 +19,3 @@ class AlertRepository:
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,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()
-15
View File
@@ -34,21 +34,6 @@ class ReadingRepository:
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,
*,
@@ -1,24 +1,11 @@
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
@@ -33,19 +20,3 @@ class RecommendationRepository:
)
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
-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]
@@ -12,9 +12,3 @@ class RecommendationResponse(BaseModel):
explanation: str
rule_reference: str
created_at: datetime
class RecommendationGenerationResponse(BaseModel):
alerts_examined: int
recommendations_created: int
already_present: int
+2 -311
View File
@@ -1,323 +1,14 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from app.models.energy import Alert, Prediction, Reading, Site
from app.models.energy import Alert
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:
def __init__(self, *, alerts: AlertRepository) -> 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
-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
)
+1 -37
View File
@@ -1,15 +1,7 @@
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):
@@ -20,24 +12,9 @@ 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:
def __init__(self, *, recommendations: RecommendationRepository) -> None:
self._recommendations = recommendations
self._alerts = alerts
self._transaction = transaction
async def list_all(self) -> Sequence[Recommendation]:
return await self._recommendations.list_all()
@@ -47,16 +24,3 @@ class RecommendationService:
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})"
+1 -305
View File
@@ -1453,90 +1453,6 @@
}
}
},
"/api/v1/recommendations/generate": {
"post": {
"tags": [
"recommendations"
],
"summary": "Génère les recommandations à partir des alertes",
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
"security": [
{
"Jeton d'accès": []
}
],
"parameters": [
{
"name": "site_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecommendationGenerationResponse"
}
}
}
},
"500": {
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InternalErrorResponse"
}
}
}
},
"401": {
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationErrorResponse"
}
}
}
}
}
}
},
"/api/v1/stats/summary": {
"get": {
"tags": [
@@ -1803,62 +1719,6 @@
}
]
}
},
"/api/v1/predictions": {
"get": {
"tags": [
"predictions"
],
"summary": "Dernière prédiction de consommation par site",
"operationId": "get_predictions_api_v1_predictions_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PredictionSummaryResponse"
}
}
}
},
"500": {
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InternalErrorResponse"
}
}
}
},
"401": {
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"Jeton d'accès": []
}
]
}
}
},
"components": {
@@ -2112,45 +1972,6 @@
],
"title": "PasswordChangeRequest"
},
"PredictionStatus": {
"type": "string",
"enum": [
"available",
"insufficient_data",
"error"
],
"title": "PredictionStatus"
},
"PredictionSummaryResponse": {
"properties": {
"timestamp": {
"type": "string",
"format": "date-time",
"title": "Timestamp"
},
"sites": {
"items": {
"$ref": "#/components/schemas/SitePredictionSummaryResponse"
},
"type": "array",
"title": "Sites"
}
},
"type": "object",
"required": [
"timestamp",
"sites"
],
"title": "PredictionSummaryResponse"
},
"PredictionTargetMetric": {
"type": "string",
"enum": [
"consumption_kwh",
"consumption_kw"
],
"title": "PredictionTargetMetric"
},
"PrincipalResponse": {
"properties": {
"id": {
@@ -2428,29 +2249,6 @@
],
"title": "ReadingSource"
},
"RecommendationGenerationResponse": {
"properties": {
"alerts_examined": {
"type": "integer",
"title": "Alerts Examined"
},
"recommendations_created": {
"type": "integer",
"title": "Recommendations Created"
},
"already_present": {
"type": "integer",
"title": "Already Present"
}
},
"type": "object",
"required": [
"alerts_examined",
"recommendations_created",
"already_present"
],
"title": "RecommendationGenerationResponse"
},
"RecommendationResponse": {
"properties": {
"recommendation_id": {
@@ -2720,104 +2518,6 @@
],
"title": "SiteCurrentResponse"
},
"SitePredictionResponse": {
"properties": {
"target_at": {
"type": "string",
"format": "date-time",
"title": "Target At"
},
"target_metric": {
"$ref": "#/components/schemas/PredictionTargetMetric"
},
"period_minutes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Period Minutes"
},
"predicted_value": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Predicted Value"
},
"status": {
"$ref": "#/components/schemas/PredictionStatus"
},
"failure_reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Failure Reason"
},
"model_reference": {
"type": "string",
"title": "Model Reference"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"target_at",
"target_metric",
"period_minutes",
"predicted_value",
"status",
"failure_reason",
"model_reference",
"created_at"
],
"title": "SitePredictionResponse"
},
"SitePredictionSummaryResponse": {
"properties": {
"site_id": {
"type": "string",
"title": "Site Id"
},
"site_name": {
"type": "string",
"title": "Site Name"
},
"prediction": {
"anyOf": [
{
"$ref": "#/components/schemas/SitePredictionResponse"
},
{
"type": "null"
}
]
}
},
"type": "object",
"required": [
"site_id",
"site_name",
"prediction"
],
"title": "SitePredictionSummaryResponse"
},
"SiteResponse": {
"properties": {
"site_id": {
@@ -3260,7 +2960,7 @@
},
{
"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`."
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`."
},
{
"name": "stats",
@@ -3273,10 +2973,6 @@
{
"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`."
}
]
}
+1 -1
View File
@@ -17,7 +17,6 @@ dependencies = [
"argon2-cffi>=23.1",
"anyio>=4.0",
"aiosmtplib>=5.1.3",
"httpx>=0.28.1",
"pandas>=3.0.5",
]
@@ -28,6 +27,7 @@ dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"httpx>=0.28.1",
"pandas-stubs>=3.0.5.260914",
]
-2
View File
@@ -51,10 +51,8 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = {
("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,
@@ -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
+1 -59
View File
@@ -10,7 +10,7 @@ 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
from app.services.recommendation import RecommendationNotFoundError
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
@@ -40,7 +40,6 @@ 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]
@@ -50,10 +49,6 @@ class FauxService:
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]:
@@ -147,56 +142,3 @@ async def test_get_recommendation_returns_404_when_the_session_finds_nothing(
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
+10 -20
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,7 +212,10 @@ async def test_alert_rejects_prediction_when_site_differs(
)
).scalar_one()
statement = insert(Alert).values(
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
await data_connection.execute(
insert(Alert).values(
source_alert_id=str(uuid4()),
site_id=other_site,
source="enervision",
@@ -230,11 +226,7 @@ async def test_alert_rejects_prediction_when_site_differs(
prediction_id=prediction_id,
raw_data={},
)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
)
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)
@@ -106,15 +106,13 @@ def test_validate_source_accepts_valid_dataset():
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,
make_metadata(),
)
@@ -126,15 +124,13 @@ def test_validate_source_rejects_duplicates():
"timestamp",
]
metadata = make_metadata()
with pytest.raises(
ValueError,
match="doublons",
):
validate_source(
frame,
metadata,
make_metadata(),
)
@@ -143,15 +139,13 @@ def test_validate_source_rejects_unknown_site():
frame.loc[1, "site_id"] = "SITE999"
metadata = make_metadata()
with pytest.raises(
ValueError,
match="Sites incohérents",
):
validate_source(
frame,
metadata,
make_metadata(),
)
@@ -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"
@@ -89,60 +89,3 @@ async def test_list_all_returns_an_empty_list_when_there_is_nothing(
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()
@@ -131,14 +131,11 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
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,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + DUREE,
client_ip=None,
user_agent=None,
)
@@ -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 == []
@@ -155,79 +155,6 @@ async def test_latest_for_site_ignores_the_readings_of_the_other_sites(
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:
@@ -5,8 +5,7 @@ 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
from app.repositories.recommendation import RecommendationRepository
pytestmark = pytest.mark.integration
@@ -84,59 +83,3 @@ async def test_list_all_returns_the_recommendations_sorted_by_identifier(
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()
+6 -408
View File
@@ -1,10 +1,7 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
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)
from app.services.alert import AlertService
def alert(
@@ -29,36 +26,10 @@ def alert(
)
@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
@@ -66,392 +37,19 @@ class FakeRepository:
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)]))
service = AlertService(alerts=FakeRepository([alert(1), alert(2)]))
alertes = await svc.list_all()
alertes = await service.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)
service = AlertService(alerts=depot)
await svc.list_all(site_id="site-1", severity="critical")
await service.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,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
+6 -4
View File
@@ -116,11 +116,13 @@ async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
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)
await service.list_history(
start=datetime(2026, 9, 2, tzinfo=UTC),
end=datetime(2026, 9, 1, tzinfo=UTC),
limit=500,
offset=0,
)
async def test_list_history_raises_when_start_equals_end() -> None:
@@ -1,14 +1,10 @@
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.models.energy import Recommendation
from app.services.recommendation import RecommendationNotFoundError, RecommendationService
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
def recommendation(recommendation_id: int = 1) -> Recommendation:
return Recommendation(
@@ -17,33 +13,13 @@ def recommendation(recommendation_id: int = 1) -> Recommendation:
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={},
created_at=datetime(2024, 1, 1, tzinfo=UTC),
)
class FakeRepository:
def __init__(self, recommendations: list[Recommendation], creees: int | None = None) -> None:
def __init__(self, recommendations: list[Recommendation]) -> None:
self._recommendations = recommendations
self._creees = creees
self.recues: list[NouvelleRecommandation] = []
async def list_all(self) -> list[Recommendation]:
return self._recommendations
@@ -53,111 +29,27 @@ class FakeRepository:
(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)])
service = RecommendationService(
recommendations=FakeRepository([recommendation(1), recommendation(2)])
)
recommendations = await service(recommendations=depot).list_all()
recommendations = await service.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)
service = RecommendationService(recommendations=FakeRepository([recommendation(1)]))
trouve = await service.get_by_id(1)
assert trouve.recommendation_id == 1
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
service = RecommendationService(recommendations=FakeRepository([]))
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
await service.get_by_id(404)
@@ -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",
}
+1 -3
View File
@@ -235,7 +235,5 @@ async def test_every_operation_refuses_an_unknown_account(action: str) -> None:
if action == "set_active":
arguments["is_active"] = False
methode = getattr(attirail.service, action)
with pytest.raises(UserNotFoundError):
await methode(**arguments)
await getattr(attirail.service, action)(**arguments)
+2 -33
View File
@@ -19,17 +19,13 @@ def test_build_parser_reads_the_create_admin_arguments() -> None:
def test_build_parser_requires_a_subcommand() -> None:
parser = cli.build_parser()
with pytest.raises(SystemExit):
parser.parse_args([])
cli.build_parser().parse_args([])
def test_build_parser_requires_an_email() -> None:
parser = cli.build_parser()
with pytest.raises(SystemExit):
parser.parse_args(["create-admin"])
cli.build_parser().parse_args(["create-admin"])
def test_read_password_generates_a_long_secret_when_asked(
@@ -122,30 +118,3 @@ def test_main_exports_the_contract_without_asking_for_a_password(
assert code == 0
assert destination.exists()
assert str(destination) in capsys.readouterr().out
def test_build_parser_reads_the_generate_recommendations_arguments() -> None:
arguments = cli.build_parser().parse_args(["generate-recommendations", "--site-id", "SITE002"])
assert arguments.commande == "generate-recommendations"
assert arguments.site_id == "SITE002"
def test_build_parser_defaults_the_generation_to_every_site() -> None:
arguments = cli.build_parser().parse_args(["generate-recommendations"])
assert arguments.site_id is None
def test_main_generates_the_recommendations_without_asking_for_a_password(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
async def fausse_generation(*, site_id: str | None) -> str:
return f"génération lancée pour {site_id}"
monkeypatch.setattr(cli, "generate_recommendations", fausse_generation)
code = cli.main(["generate-recommendations", "--site-id", "SITE002"])
assert code == 0
assert "SITE002" in capsys.readouterr().out
@@ -1,87 +0,0 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_session_factory
from app.detection import internal_alerts
from app.repositories.alert import AlertRepository
from tests.repositories.test_reading import creer_lecture
from tests.repositories.test_site import creer as creer_site
def test_parse_args_defaults_to_no_site_and_no_instant() -> None:
arguments = internal_alerts.parse_args([])
assert arguments.site_id is None
assert arguments.now is None
def test_parse_args_reads_the_site_id() -> None:
arguments = internal_alerts.parse_args(["--site-id", "site-1"])
assert arguments.site_id == "site-1"
def test_parse_args_parses_the_instant_option() -> None:
arguments = internal_alerts.parse_args(["--now", "2026-09-16T12:00:00+00:00"])
assert arguments.now == datetime(2026, 9, 16, 12, tzinfo=UTC)
def test_parse_instant_treats_a_naive_datetime_as_utc() -> None:
assert internal_alerts._parse_instant("2026-09-16T12:00:00") == datetime(
2026, 9, 16, 12, tzinfo=UTC
)
def test_main_prints_how_many_alerts_were_recorded(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
async def fausse_execution(*, now: datetime | None, site_id: str | None) -> int:
return 3
monkeypatch.setattr(internal_alerts, "run_detection", fausse_execution)
code = internal_alerts.main([])
assert code == 0
assert "3 nouvelle" in capsys.readouterr().out
@pytest.mark.integration
async def test_run_detection_writes_a_threshold_alert_end_to_end(session: AsyncSession) -> None:
# `run_detection` ouvre sa propre session et commite : `session.rollback()` seul ne défait
# rien ici (contrairement au reste de la suite), d'où le nettoyage explicite ci-dessous, sur
# le modèle de `tests/api/test_matrice_acces.py`.
site = await creer_site(session, capacity_kw=100.0)
site_id = site.site_id
instant = datetime(2026, 9, 16, 12, tzinfo=UTC)
await creer_lecture(session, site_id=site_id, timestamp=instant, consumption_kw=150.0)
await session.commit()
try:
nombre = await internal_alerts.run_detection(now=instant, site_id=site_id)
alertes = await AlertRepository(session).list_all(site_id=site_id)
types = [a.type for a in alertes]
await session.rollback()
assert nombre == 1
assert types == ["threshold"]
finally:
# `site.site_id` n'est plus sûr après `session.rollback()` : le rollback expire tous les
# objets de la session (indépendamment d'`expire_on_commit`), et y accéder ici relance une
# requête hors contexte async. D'où `site_id`, capturé avant.
async with get_session_factory()() as nettoyage:
await nettoyage.execute(
text("delete from alert where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.execute(
text("delete from reading where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.execute(
text("delete from site where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.commit()
+2 -2
View File
@@ -326,7 +326,6 @@ dependencies = [
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pandas" },
{ name = "prometheus-fastapi-instrumentator" },
{ name = "pydantic", extra = ["email"] },
@@ -339,6 +338,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pandas-stubs" },
{ name = "pytest" },
@@ -355,7 +355,6 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pandas", specifier = ">=3.0.5" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
@@ -368,6 +367,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=2.3.1" },
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
{ name = "pytest", specifier = ">=9.1.1" },
+1 -1
View File
@@ -3,7 +3,7 @@
# ==================
# Image pour frontend
FROM node:26-alpine3.22 AS builder
FROM node:24-alpine3.22 AS builder
WORKDIR /app
-5
View File
@@ -34,11 +34,6 @@
},
"configurations": {
"production": {
"optimization": {
"styles": {
"inlineCritical": false
}
},
"budgets": [
{
"type": "initial",
-7
View File
@@ -23,11 +23,4 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
},
{
path: 'monitoring/sensors',
canActivate: [authGuard],
data: { role: 'admin' },
loadComponent: () =>
import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView),
},
];
@@ -64,13 +64,4 @@ describe('mockApiInterceptor', () => {
httpMock.expectNone(`${environment.apiUrl}/alerts`);
expect((result as unknown[]).length).toBeGreaterThan(0);
});
it('laisse toujours passer /predictions vers le réseau, même avec useMockFixtures activé', () => {
environment.useMockFixtures = true;
http.get(`${environment.apiUrl}/predictions`).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/predictions`);
req.flush({ timestamp: '2026-09-18T09:00:00Z', sites: [] });
});
});
@@ -26,7 +26,5 @@ export const mockApiInterceptor: HttpInterceptorFn = (req, next) => {
if (req.url.endsWith(`${environment.apiUrl}/alerts`)) {
return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE }));
}
// Volontairement jamais mocké, contrairement à `stats`/`alerts` : les prévisions sont servies
// par l'API réelle dès maintenant (au même titre que `/auth/*`, déjà toujours réel).
return next(req);
};
@@ -1,35 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { PredictionsService } from './predictions.service';
import { environment } from '../../../environments/environment';
describe('PredictionsService', () => {
let service: PredictionsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(PredictionsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('appelle le bon endpoint et retourne un résumé de prévisions', () => {
let result: unknown;
service.getPredictions().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/predictions`);
expect(req.request.method).toBe('GET');
req.flush({
timestamp: '2026-09-18T09:00:00Z',
sites: [{ site_id: 'SITE001', site_name: 'Test', prediction: null }],
});
expect((result as { sites: unknown[] }).sites.length).toBe(1);
});
});
@@ -1,13 +0,0 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { PredictionSummary } from '../../shared/models/prediction.model';
@Service()
export class PredictionsService {
private http = inject(HttpClient);
getPredictions() {
return this.http.get<PredictionSummary>(`${environment.apiUrl}/predictions`);
}
}
@@ -1,48 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { SensorsService } from './sensors.service';
import { environment } from '../../../environments/environment';
describe('SensorsService', () => {
let service: SensorsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(SensorsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it("appelle l'endpoint /sensors/status et retourne la réponse", () => {
let result: unknown;
service.getStatus().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sensors/status`);
expect(req.request.method).toBe('GET');
req.flush({
timestamp: '2026-09-18T08:00:00',
sites: [
{
site_id: 'SITE001',
site_name: 'Test',
overall: 'ok',
sensors: {
consumption: { status: 'ok', since: null },
electrical: { status: 'ok', since: null },
temperature: { status: 'ok', since: null },
humidity: { status: 'ok', since: null },
network: { status: 'ok', since: null },
},
},
],
});
expect((result as { sites: unknown[] }).sites.length).toBe(1);
});
});
@@ -1,13 +0,0 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import {SensorStatusResponse} from '../../shared/models/sensor-status.model';
@Service()
export class SensorsService {
private http = inject(HttpClient);
getStatus() {
return this.http.get<SensorStatusResponse>(`${environment.apiUrl}/sensors/status`);
}
}
@@ -10,9 +10,6 @@
</div>
</div>
<div class="dashboard__actions">
@if (auth.principal()?.role === 'admin') {
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
}
<a routerLink="/sites" class="ev-link">Voir les sites</a>
<ev-button
class="logout-button"
@@ -24,13 +21,7 @@
</div>
</header>
@if (statsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (alertsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (predictionsError(); as message) {
@if (error(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@@ -81,33 +72,4 @@
</ul>
</section>
}
@if (predictions().length > 0) {
<section class="predictions-section">
<h2>Prévisions de consommation</h2>
<ul class="predictions-list">
@for (site of predictions(); track site.site_id) {
<li class="prediction-item">
<span class="prediction-item__site">{{ site.site_name }}</span>
@if (site.prediction; as prediction) {
@if (prediction.status === 'available') {
<span class="prediction-item__value">
{{ prediction.predicted_value | number: '1.0-1' }} kWh
<span class="prediction-item__target"
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
>
</span>
} @else {
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur'
}}</ev-badge>
}
} @else {
<ev-badge tone="neutral">Pas encore de prévision</ev-badge>
}
</li>
}
</ul>
</section>
}
</div>
@@ -68,15 +68,6 @@ h2 {
text-align: center;
}
.card--link {
cursor: pointer;
transition: border-color 0.15s ease;
&:hover {
border-color: var(--color-primary);
}
}
.card__label {
font-size: 0.8rem;
color: var(--color-text-muted);
@@ -130,40 +121,3 @@ h2 {
.alert-item__message {
font-size: 0.9rem;
}
.predictions-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prediction-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
}
.prediction-item__site {
font-size: 0.9rem;
font-weight: 600;
}
.prediction-item__value {
font-size: 0.9rem;
font-weight: 600;
}
.prediction-item__target {
margin-left: 0.35rem;
font-size: 0.8rem;
font-weight: 400;
color: var(--color-text-muted);
}
@@ -4,7 +4,6 @@ import { of, throwError } from 'rxjs';
import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service';
import { PredictionsService } from '../../core/services/predictions.service';
import {AuthService} from '../../core/services/auth.service';
import {Router, provideRouter} from '@angular/router';
@@ -18,24 +17,18 @@ vi.mock('chart.js', () => {
return { Chart: ChartMock, registerables: [] };
});
function predictionsMock(sites: unknown[] = []) {
return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
}
describe('Dashboard', () => {
afterEach(() => vi.useRealTimers());
it('charge les stats, les alertes et les prévisions au démarrage', async () => {
it('charge les stats et les alertes au démarrage', async () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
});
@@ -49,12 +42,8 @@ describe('Dashboard', () => {
expect(statsMock.getSummary).toHaveBeenCalled();
expect(alertsMock.getAlerts).toHaveBeenCalled();
expect(predictions.getPredictions).toHaveBeenCalled();
expect(fixture.componentInstance.alerts().length).toBe(1);
expect(fixture.componentInstance.predictions().length).toBe(1);
expect(fixture.componentInstance.statsError()).toBeNull();
expect(fixture.componentInstance.alertsError()).toBeNull();
expect(fixture.componentInstance.predictionsError()).toBeNull();
expect(fixture.componentInstance.error()).toBeNull();
});
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
@@ -72,7 +61,6 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
@@ -82,13 +70,13 @@ describe('Dashboard', () => {
vi.advanceTimersByTime(1);
expect(statsMock.getSummary).toHaveBeenCalledTimes(1);
expect(fixture.componentInstance.statsError()).not.toBeNull();
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.stats()).toBeNull();
vi.advanceTimersByTime(10000);
expect(statsMock.getSummary).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.stats()).not.toBeNull();
expect(fixture.componentInstance.statsError()).toBeNull();
expect(fixture.componentInstance.error()).toBeNull();
});
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
@@ -100,7 +88,6 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
@@ -109,78 +96,18 @@ describe('Dashboard', () => {
fixture.detectChanges();
expect(fixture.componentInstance.alerts().length).toBe(0);
expect(fixture.componentInstance.alertsError()).not.toBeNull();
});
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const predictions = {
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.predictions().length).toBe(0);
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
});
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
vi.useFakeTimers();
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const predictions = {
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
// toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée.
vi.advanceTimersByTime(30000);
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
expect(fixture.componentInstance.statsError()).toBeNull();
});
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = {
logout: vi.fn().mockReturnValue(of(undefined)),
clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
};
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock },
provideRouter([]),
],
@@ -204,14 +131,12 @@ describe('Dashboard', () => {
const authMock = {
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock },
provideRouter([]),
],
@@ -239,7 +164,6 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
@@ -255,26 +179,4 @@ describe('Dashboard', () => {
dashboard.badgeToneForSeverity('critical'),
);
});
it('distingue le ton des statuts de prévision', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
expect(dashboard.badgeToneForPredictionStatus('insufficient_data')).toBe('warning');
expect(dashboard.badgeToneForPredictionStatus('error')).toBe('danger');
});
});
@@ -1,17 +1,15 @@
import { Component, OnInit, inject, signal, DestroyRef, WritableSignal } from '@angular/core';
import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
import { DecimalPipe, DatePipe } from '@angular/common';
import { DecimalPipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
import { AlertsService } from '../../core/services/alerts.service';
import { PredictionsService } from '../../core/services/predictions.service';
import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
import { Card } from '../../shared/components/ui/card/card';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge';
@@ -29,21 +27,11 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
critical: 'critical',
};
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
// tomber sur `undefined` si ce statut apparaît un jour.
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
available: 'success',
insufficient_data: 'warning',
error: 'danger',
};
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
DecimalPipe,
DatePipe,
RouterLink,
ConsumptionGauge,
SiteLoadChart,
@@ -59,52 +47,31 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
export class Dashboard implements OnInit {
private statsService = inject(StatsService);
private alertsService = inject(AlertsService);
public auth = inject(AuthService);
private predictionsService = inject(PredictionsService);
private auth = inject(AuthService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
stats = signal<StatsSummary | null>(null);
alerts = signal<Alert[]>([]);
predictions = signal<SitePredictionSummary[]>([]);
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
statsError = signal<string | null>(null);
alertsError = signal<string | null>(null);
predictionsError = signal<string | null>(null);
error = signal<string | null>(null);
ngOnInit(): void {
this.alertsService
.getAlerts()
.pipe(catchError(() => this.reportUnavailable(this.alertsError)))
.subscribe((alerts) => {
this.alertsError.set(null);
this.alerts.set(alerts);
});
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
this.predictionsService
.getPredictions()
.pipe(catchError(() => this.reportUnavailable(this.predictionsError)))
.subscribe((summary) => {
this.predictionsError.set(null);
this.predictions.set(summary.sites);
});
.pipe(catchError(() => this.reportUnavailable()))
.subscribe((alerts) => this.alerts.set(alerts));
// Piège : le catchError porte sur l'observable interne. Sur le flux externe il
// terminerait le timer, et le rafraîchissement ne repartirait jamais.
timer(0, REFRESH_INTERVAL_MS)
.pipe(
switchMap(() =>
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((stats) => {
this.statsError.set(null);
this.error.set(null);
this.stats.set(stats);
});
}
@@ -113,10 +80,6 @@ export class Dashboard implements OnInit {
return TON_PAR_SEVERITE[severity];
}
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
return TON_PAR_STATUT_PREDICTION[status];
}
onLogout(): void {
this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']),
@@ -128,8 +91,8 @@ export class Dashboard implements OnInit {
});
}
private reportUnavailable(target: WritableSignal<string | null>): Observable<never> {
target.set(UNAVAILABLE_MESSAGE);
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -1,51 +0,0 @@
<div class="sensor-status">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
</nav>
<header class="sensor-status__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="sensor-status__logo" />
</a>
<div>
<h1>Supervision des capteurs</h1>
<p class="sensor-status__subtitle">État de santé par capteur et par site</p>
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (data(); as d) {
<div class="sites-grid">
@for (site of d.sites; track site.site_id) {
<ev-card class="site-card">
<div class="site-card__header">
<span class="site-card__name">{{ site.site_name }}</span>
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
</div>
<ul class="sensor-list">
@for (entry of sensorEntries; track entry[0]) {
@let diagnostic = sensorOf(site.sensors, entry[0]);
<li class="sensor-item">
<span class="sensor-dot" [class]="'sensor-dot--' + diagnostic.status"></span>
<span class="sensor-item__label">{{ entry[1] }}</span>
@if (diagnostic.status === 'failing') {
<span class="sensor-item__since">
@if (diagnostic.since; as since) {
dernière lecture le {{ since | date: 'dd/MM/yyyy HH:mm' }}
} @else {
aucune lecture reçue
}
</span>
}
</li>
}
</ul>
</ev-card>
}
</div>
}
</div>
@@ -1,90 +0,0 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.sensor-status__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.sensor-status__logo {
font-size: 1.3rem;
}
.sensor-status__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.banner-error {
display: block;
margin: 0 0 1.5rem;
}
.sites-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.site-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.site-card__name {
font-weight: 600;
}
.sensor-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sensor-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.sensor-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&--ok {
background: var(--color-success);
}
&--failing {
background: var(--color-danger);
}
}
.sensor-item__label {
flex: 1;
}
.sensor-item__since {
color: var(--color-text-muted);
font-size: 0.75rem;
}
@@ -1,122 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { SensorStatusView } from './sensor-status';
import { SensorsService } from '../../../core/services/sensors.service';
import { SiteSensors } from '../../../shared/models/sensor-status.model';
import {provideRouter} from '@angular/router';
const OK_SENSORS: SiteSensors = {
consumption: { status: 'ok', since: null },
electrical: { status: 'ok', since: null },
temperature: { status: 'ok', since: null },
humidity: { status: 'ok', since: null },
network: { status: 'ok', since: null },
};
describe('SensorStatusView', () => {
let sensorsMock: { getStatus: ReturnType<typeof vi.fn> };
beforeEach(() => {
sensorsMock = { getStatus: vi.fn() };
TestBed.configureTestingModule({
imports: [SensorStatusView],
providers: [
{ provide: SensorsService, useValue: sensorsMock },
provideRouter([]),
],
});
});
it('charge et affiche les données au démarrage', () => {
sensorsMock.getStatus.mockReturnValue(
of({
timestamp: '2026-09-18T08:00:00',
sites: [
{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'ok', sensors: OK_SENSORS },
],
})
);
const fixture = TestBed.createComponent(SensorStatusView);
fixture.detectChanges();
expect(fixture.componentInstance.data()?.sites.length).toBe(1);
expect(fixture.componentInstance.error()).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Bureau Test');
});
it("affiche un message d'erreur si l'appel échoue", () => {
sensorsMock.getStatus.mockReturnValue(throwError(() => new Error('boom')));
const fixture = TestBed.createComponent(SensorStatusView);
fixture.detectChanges();
expect(fixture.componentInstance.error()).toBe(
'État des capteurs indisponible, réessayez plus tard.'
);
expect(fixture.componentInstance.data()).toBeNull();
expect(fixture.nativeElement.textContent).toContain('État des capteurs indisponible');
});
it('associe le bon ton de badge à chaque statut global', () => {
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
const fixture = TestBed.createComponent(SensorStatusView);
const component = fixture.componentInstance;
expect(component.badgeToneForOverall('ok')).toBe('success');
expect(component.badgeToneForOverall('degraded')).toBe('warning');
expect(component.badgeToneForOverall('critical')).toBe('critical');
expect(component.badgeToneForOverall('inconnu')).toBe('neutral');
});
it('retourne le bon diagnostic via sensorOf', () => {
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
const fixture = TestBed.createComponent(SensorStatusView);
const component = fixture.componentInstance;
expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null });
});
it('affiche la date de la dernière lecture reçue pour un capteur en panne', () => {
const sensors: SiteSensors = {
...OK_SENSORS,
temperature: { status: 'failing', since: '2026-09-18T08:00:00' },
};
sensorsMock.getStatus.mockReturnValue(
of({
timestamp: '2026-09-18T08:00:00',
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }],
})
);
const fixture = TestBed.createComponent(SensorStatusView);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('dernière lecture le');
expect(fixture.nativeElement.textContent).toContain('18/09/2026 08:00');
});
it("annonce l'absence de lecture quand un site n'en a jamais reçu", () => {
const sensors: SiteSensors = {
consumption: { status: 'failing', since: null },
electrical: { status: 'failing', since: null },
temperature: { status: 'failing', since: null },
humidity: { status: 'failing', since: null },
network: { status: 'failing', since: null },
};
sensorsMock.getStatus.mockReturnValue(
of({
timestamp: '2026-09-18T08:00:00',
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'critical', sensors }],
})
);
const fixture = TestBed.createComponent(SensorStatusView);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('aucune lecture reçue');
expect(fixture.nativeElement.textContent).not.toContain('dernière lecture le');
});
});
@@ -1,65 +0,0 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { catchError, EMPTY, Observable } from 'rxjs';
import {Badge, BadgeTone} from '../../../shared/components/ui/badge/badge';
import {Card} from '../../../shared/components/ui/card/card';
import {Alert} from '../../../shared/components/ui/alert/alert';
import {Brand} from '../../../shared/components/ui/brand/brand';
import {SensorsService} from '../../../core/services/sensors.service';
import {SensorDiagnostic, SensorStatusResponse} from '../../../shared/models/sensor-status.model';
import { DatePipe } from '@angular/common';
const UNAVAILABLE_MESSAGE = 'État des capteurs indisponible, réessayez plus tard.';
const SENSOR_LABELS: Record<string, string> = {
consumption: 'Consommation',
electrical: 'Électrique',
temperature: 'Température',
humidity: 'Humidité',
network: 'Réseau',
};
const TON_PAR_OVERALL: Record<string, BadgeTone> = {
ok: 'success',
degraded: 'warning',
critical: 'critical',
};
@Component({
selector: 'app-sensor-status',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand, DatePipe],
templateUrl: './sensor-status.html',
styleUrl: './sensor-status.scss',
})
export class SensorStatusView implements OnInit {
private sensorsService = inject(SensorsService);
data = signal<SensorStatusResponse | null>(null);
error = signal<string | null>(null);
readonly sensorEntries = Object.entries(SENSOR_LABELS);
ngOnInit(): void {
this.sensorsService
.getStatus()
.pipe(catchError(() => this.reportUnavailable()))
.subscribe((response) => {
this.error.set(null);
this.data.set(response);
});
}
sensorOf(sensors: Record<string, SensorDiagnostic>, key: string): SensorDiagnostic {
return sensors[key];
}
badgeToneForOverall(overall: string): BadgeTone {
return TON_PAR_OVERALL[overall] ?? 'neutral';
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -1,24 +0,0 @@
export type PredictionStatus = 'available' | 'insufficient_data' | 'error';
export type PredictionTargetMetric = 'consumption_kwh' | 'consumption_kw';
export interface SitePrediction {
target_at: string;
target_metric: PredictionTargetMetric;
period_minutes: number | null;
predicted_value: number | null;
status: PredictionStatus;
failure_reason: string | null;
model_reference: string;
created_at: string;
}
export interface SitePredictionSummary {
site_id: string;
site_name: string;
prediction: SitePrediction | null;
}
export interface PredictionSummary {
timestamp: string;
sites: SitePredictionSummary[];
}
@@ -1,28 +0,0 @@
export type SensorStatus = 'ok' | 'failing';
export type OverallStatus = 'ok' | 'degraded' | 'critical';
export interface SensorDiagnostic {
status: SensorStatus;
since: string | null;
}
export interface SiteSensors {
consumption: SensorDiagnostic;
electrical: SensorDiagnostic;
temperature: SensorDiagnostic;
humidity: SensorDiagnostic;
network: SensorDiagnostic;
[key: string]: SensorDiagnostic;
}
export interface SiteSensorStatus {
site_id: string;
site_name: string;
sensors: SiteSensors;
overall: OverallStatus;
}
export interface SensorStatusResponse {
timestamp: string;
sites: SiteSensorStatus[];
}
-5
View File
@@ -1,5 +0,0 @@
-- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base
-- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire
-- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un
-- service dedie, pour ne pas ajouter un conteneur de plus (issue #115).
CREATE DATABASE airflow;
-74
View File
@@ -1,74 +0,0 @@
# Piège : `APP_ENV` et `APP_DEBUG` sont en dur et non en `${APP_ENV:-prod}` : le `.env` du poste
# vaut `local` et reprendrait le dessus, ce qui laisserait le cookie sans `__Secure-` et
# rouvrirait `/docs`. Hors `local`, l'API exige en retour une origine CORS non vide.
# Piège : les listes de ports se cumulent à la fusion des deux fichiers. `!reset` est le seul
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
name: enervision
services:
db:
ports: !override
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
mailpit:
ports: !override
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
airflow-webserver:
ports: !override
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
backend:
ports: !reset null
command:
- uvicorn
- app.main:create_app
- --factory
- --host
- 0.0.0.0
- --port
- "8000"
- --proxy-headers
- --forwarded-allow-ips=*
environment:
APP_ENV: prod
APP_DEBUG: "false"
APP_TRUST_PROXY_HEADERS: "true"
APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local}
APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password
frontend:
ports: !reset null
proxy:
image: nginx:1.28-alpine
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
ports:
- "80:80"
- "443:443"
volumes:
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
- ./infra/proxy/tls:/etc/nginx/tls:ro
- acme_webroot:/var/www/certbot
restart: unless-stopped
certbot:
image: certbot/certbot:v5.8.0
profiles: ["acme"]
volumes:
- letsencrypt:/etc/letsencrypt
- acme_webroot:/var/www/certbot
- ./infra/proxy/tls:/tls
- ./infra/proxy/acme-deploy-hook.sh:/deploy-hook.sh:ro
volumes:
acme_webroot:
letsencrypt:
+1 -101
View File
@@ -5,41 +5,6 @@
name: enervision
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du
# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte
# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce
# dernier puisse au besoin l'inspecter sans en devenir dependant.
x-airflow-common: &airflow-common
build:
context: .
dockerfile: etl/airflow/Dockerfile
environment: &airflow-common-env
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
# Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de
# filtrer les services : une variable requise manquante casserait aussi `make db-up`,
# `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par
# `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent.
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-}
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) :
# memes identifiants que le backend en attendant.
ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db
# Le DAG `alertes` lance le backend en sous-processus : il lit `DATABASE_URL`, en
# dialecte asyncpg, là où le pipeline ML lit `ML_DATABASE_URL`.
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
# Clé distincte de celle de l'API : la détection ne signe ni ne vérifie aucun jeton, et
# Airflow permet d'exécuter du code depuis son interface (cf. ADR 0008).
APP_SECRET_KEY: ${AIRFLOW_APP_SECRET_KEY:-}
volumes:
- ./etl/airflow/dags:/opt/airflow/dags
- ./etl/airflow/plugins:/opt/airflow/plugins
- airflow_logs:/opt/airflow/logs
- airflow_ml_state:/opt/ml/state
restart: unless-stopped
services:
db:
image: timescale/timescaledb-ha:pg17
@@ -54,7 +19,6 @@ services:
- pgdata:/home/postgres/pgdata/data
- ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro
- ./db/init/110-test-database.sql:/docker-entrypoint-initdb.d/110-test-database.sql:ro
- ./db/init/120-airflow-database.sql:/docker-entrypoint-initdb.d/120-airflow-database.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
@@ -86,12 +50,6 @@ services:
APP_SECRET_KEY: ${APP_SECRET_KEY:?}
APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr}
APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-}
APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-}
APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10}
APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password}
APP_SMTP_HOST: mailpit
APP_SMTP_PORT: "1025"
@@ -104,67 +62,9 @@ services:
frontend:
build: ./apps/frontend
ports:
- "${FRONTEND_PORT:-3000}:3000"
- "${FRONTEND_PORT:-3000}:80"
restart: unless-stopped
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
# portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte
# aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume
# `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son
# succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement,
# jamais par `argv` (ni `ps`, ni `docker compose config`).
# Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous
# refuse en plus les deux cles de chiffrement vides.
airflow-init:
<<: *airflow-common
restart: "no"
environment:
<<: *airflow-common-env
_AIRFLOW_DB_MIGRATE: "true"
_AIRFLOW_WWW_USER_CREATE: "true"
_AIRFLOW_WWW_USER_USERNAME: ${AIRFLOW_ADMIN_USERNAME:-admin}
_AIRFLOW_WWW_USER_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-}
_AIRFLOW_WWW_USER_EMAIL: ${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}
depends_on:
db:
condition: service_healthy
command:
- bash
- -c
- |
set -euo pipefail
: "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}"
: "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}"
: "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}"
exec airflow version
airflow-webserver:
<<: *airflow-common
command: webserver
ports:
- "${AIRFLOW_PORT:-8080}:8080"
depends_on:
db:
condition: service_healthy
airflow-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
airflow-scheduler:
<<: *airflow-common
command: scheduler
depends_on:
db:
condition: service_healthy
airflow-init:
condition: service_completed_successfully
volumes:
pgdata:
airflow_logs:
airflow_ml_state:
-177
View File
@@ -1,177 +0,0 @@
# ML-START : accès aux données, scoring, frontière API et ML
Document de référence du module `ml/`, cité par le code (`enervision_ml/config.py`, `data.py`,
`train.py`, `score.py`, `features.py`), par l'[ADR 0005](adr/0005-modele-prediction-lightgbm.md)
et par les vues d'architecture. Il répond à trois questions, et à elles seules :
1. **comment le pipeline accède aux données**, et pourquoi pas par l'API ;
2. **ce que fait un run de scoring**, étape par étape ;
3. **où passe la frontière entre l'API et le ML**, et pourquoi elle est là.
Le mode d'emploi (installation, commandes, options) est dans [`ml/README.md`](../ml/README.md).
Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas.
---
## 1. Mécanisme d'accès aux données
### Deux sources, un seul schéma de sortie
`enervision_ml.data` expose trois chargeurs qui produisent **exactement les mêmes neuf colonnes**
(`site_id`, `timestamp`, `consumption_kwh`, `temperature_celsius`, `humidity_percent`,
`solar_irradiance_wm2`, `is_working_hours`, `site_type`, `capacity_kw`) :
| Fonction | Source | Usage |
|---|---|---|
| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée |
| `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement |
| `load_recent_from_database(connection, since=…)` | `reading` joint à `site`, **borné par `since`** | Scoring |
L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur
CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une
divergence entre les deux chemins ne se verrait pas au chargement, elle se verrait en production
sous forme de prédictions silencieusement fausses.
### Connexion directe à PostgreSQL, pas l'API
Le pipeline lit `reading` et `site` **en SQL direct**, jamais par `GET /api/v1/readings`. Trois
raisons, à défendre telles quelles :
- **Volume.** L'entraînement lit l'historique complet d'une hypertable TimescaleDB. Le faire
passer par une API REST paginée, sérialisée en JSON et contrôlée route par route, c'est payer
trois fois pour un `SELECT`.
- **Couplage.** Le pipeline n'est pas un client de l'application, c'est un consommateur du
schéma. Passer par l'API le rendrait dépendant du contrat HTTP, de l'authentification et de la
disponibilité du service, pour lire des données dont il connaît déjà la forme.
- **Droits.** Un rôle de lecture sur deux tables est une surface plus petite qu'un compte
applicatif porteur d'un rôle métier.
### `ML_DATABASE_URL`, et pourquoi ce n'est pas `DATABASE_URL`
La chaîne de connexion est lue dans **`ML_DATABASE_URL`**, jamais dans `DATABASE_URL`. Ce n'est
pas une préférence de nommage : `DATABASE_URL` est celle du backend applicatif, **propriétaire du
schéma**, avec les droits d'écriture complets. Réutiliser cette variable par défaut ferait tourner
l'entraînement et le scoring avec ces droits, **en silence**. `enervision_ml.config.database_url()`
lève donc plutôt que de retomber sur une valeur par défaut.
**Dette assumée, à dire à l'oral et non à masquer** : le rôle PostgreSQL dédié `enervision_ml`,
restreint en lecture sur `reading` et `site`, **n'est pas provisionné**. En développement,
`ML_DATABASE_URL` pointe sur la même base que le backend. La cible est un rôle séparé, cohérente
avec le principe de moindre privilège posé par l'[ADR 0003](adr/0003-autorisation-rbac-a-trois-roles.md).
### Le seul endroit qui construit les features
`enervision_ml.features.build_features` est **l'unique** constructeur de features, à
l'entraînement comme au scoring. Le piège que cela évite : si les deux divergent, même d'une
fenêtre de moyenne glissante, le modèle reçoit en service des features qui ne ressemblent plus à
ce qu'il a appris, et ses prédictions se dégradent **sans qu'aucune erreur ne se déclenche**.
Ne jamais réécrire cette logique ailleurs : importer le module.
Conséquence sur la validation : la coupure entraînement / validation est **chronologique**, jamais
un tirage aléatoire de lignes. Un tirage aléatoire laisserait des lignes de validation voir des
lignes d'entraînement à travers leurs lags et leurs moyennes glissantes, une fuite qui masquerait
un surapprentissage.
---
## 2. Les étapes d'un run de scoring
`python -m enervision_ml.score` calcule, pour chaque site ou pour un seul avec `--site-id`, la
consommation prévue de **l'heure suivant sa dernière lecture connue**, et écrit une ligne dans
`prediction`.
| # | Étape | Point de vigilance |
|---|---|---|
| 1 | Charger une **fenêtre récente** de `reading` joint à `site` : 21 jours par défaut | Une marge au-dessus des 168 h qu'exige le lag hebdomadaire. Un `SELECT` non borné sur l'hypertable serait la même erreur que celle corrigée sur `GET /readings` |
| 2 | Ajouter **une ligne future par site**, l'heure suivante, et calculer ses features par `build_features` | La même fonction qu'à l'entraînement, cf. section 1 |
| 3 | Si le **lag de 168 h est absent** (moins d'une semaine d'historique) : écrire `status = "insufficient_data"` | **LightGBM n'est jamais appelé.** Un modèle interrogé sans son lag principal rendrait un nombre, et ce nombre serait faux sans le dire |
| 4 | Sinon : `booster.predict(...)`, puis écrire `status = "available"` et la valeur prévue | |
### Ce que le run écrit, et ce qu'il n'écrase pas
La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run
insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra
possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui
n'existe pas encore.
Trois contraintes de cohérence sont portées par la base et non par le code applicatif :
`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ;
`insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à
`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`.
### `model_reference` est un hachage, pas un nom de fichier
`train.py` réécrit **toujours le même chemin** (`models/lightgbm-consumption.txt`) à chaque
entraînement. Le nom de fichier ne distinguerait donc pas deux versions du modèle. `prediction`
porte pour cela le **SHA-256 tronqué du fichier modèle**. C'est ce qui permet, devant une
prédiction douteuse, de savoir quel modèle l'a produite.
### Mode CSV : rien n'est écrit en base
En `--csv`, le run ne touche pas la base. L'heure future calculée depuis la fin du CSV n'existe
dans aucune base réelle : ce serait inscrire une prévision pour un instant déjà passé. Le mode
sert à valider le pipeline sans base joignable.
### Limite assumée
La feature `is_working_hours` de la ligne future est **recopiée** depuis la dernière lecture
réelle, pas recalculée : il n'existe aucune règle d'heures ouvrables dans ce dépôt, elle vit dans
le générateur du jeu de données d'origine. L'approximation n'est fausse qu'aux heures de bascule,
sur une feature parmi une dizaine, pour une prévision à un seul pas.
---
## 3. La frontière entre l'API et le ML
```mermaid
flowchart LR
subgraph ml["ml/ · projet Python indépendant"]
train["enervision_ml.train<br/>LightGBM + MLflow"]
score["enervision_ml.score<br/>prévision à un pas"]
end
subgraph db["PostgreSQL + TimescaleDB"]
reading[("reading, site")]
prediction[("prediction")]
end
subgraph api["apps/backend · FastAPI"]
route["GET /api/v1/predictions"]
end
reading -- "SQL direct, ML_DATABASE_URL" --> train
reading -- "fenêtre récente" --> score
train -- "models/*.txt + run MLflow" --> score
score -- "INSERT" --> prediction
prediction -- "lecture seule" --> route
```
**La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.**
`GET /api/v1/predictions` lit la dernière prévision par site dans `prediction`, jamais un recalcul
à la volée. Ce qui en découle, et qui est l'argument à tenir devant le jury :
- **La latence de l'API ne dépend pas du modèle.** Une route de lecture indexée
(`ix_prediction_site_target`) répond en temps constant, qu'un run de scoring dure une seconde
ou une minute.
- **Le service de production n'embarque ni LightGBM ni MLflow.** `ml/` est un projet Python
séparé, avec son propre `uv.lock`. Le backend n'a aucune raison de porter ces dépendances, ni
leur surface de vulnérabilités, pour un script lancé hors du chemin de requête.
- **Une panne du pipeline dégrade, elle n'interrompt pas.** Si le scoring ne tourne plus, l'API
continue de servir la dernière prévision connue, avec son `created_at` et son
`model_reference`, au lieu de rendre une erreur.
- **Le contrat est la table, pas un appel.** Ce qui traverse la frontière, ce sont des lignes de
`prediction` et leurs contraintes de cohérence, vérifiables en SQL.
Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est
celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly`
(issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme
l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la
surveillance de dérive, portée par les issues #44 et #45.
---
## Voir aussi
- [`ml/README.md`](../ml/README.md) : installation, commandes, options, où écrire les tests
- [ADR 0005](adr/0005-modele-prediction-lightgbm.md) : pourquoi LightGBM, et les 6 candidats écartés
- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions
- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions`
- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données
-4
View File
@@ -11,7 +11,3 @@
| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête |
| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
| [0005](adr/0005-modele-prediction-lightgbm.md) | LightGBM pour la prédiction de consommation, un modèle global |
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
@@ -1,81 +0,0 @@
# 0006 - Le moteur de règles de recommandation vit dans le backend
- Statut : accepté
- Date : 2026-09-18
## Contexte
L'issue #38 demande un « moteur de règles pour recommandations », portée par le label `ml`. Le
schéma tranche déjà la forme du résultat : `recommendation(alert_id, action, explanation,
rule_reference)`, avec `alert_id` en clé étrangère `NOT NULL` et une contrainte d'unicité
`uq_recommendation_alert_rule` sur `(alert_id, rule_reference)`. Une recommandation est donc
**dérivée d'une alerte**, jamais d'une mesure brute ni d'une prévision.
Deux emplacements se disputaient le code :
1. `ml/enervision_ml/`, sur le patron de `enervision_ml.score` livré par #37 : un script autonome
qui se connecte par `ML_DATABASE_URL`, écrit une table, et que l'API se contente de lire.
L'[ADR 0005](0005-modele-prediction-lightgbm.md) annonce d'ailleurs #38 de ce côté, en écrivant
que le scoring, le moteur de recommandations et les tests de dérive « consommeront le même
module `enervision_ml.features` ».
2. `apps/backend/app/services/`, où `apps/backend/README.md` place les « regles metier ».
## Décision
**Le moteur vit dans `apps/backend/app/services/`**, sous la forme d'un module pur
`recommendation_rules.py` (le catalogue `REGLES`) et d'une méthode `RecommendationService.generate()`
qui l'applique, persiste et valide la transaction.
Trois raisons :
- **Il n'utilise rien du ML.** Le catalogue lit `alert.type`, `alert.severity`, `alert.value` et
`alert.threshold`. Aucun modèle, aucune feature, aucun `enervision_ml.features` : la phrase de
l'ADR 0005 vaut pour le scoring (#37) et les tests de dérive (#44/#45), qui manipulent bien des
features, pas pour des règles sur alertes. Le label `ml` de #38 désigne le lot fonctionnel
« prédiction et recommandation », pas l'emplacement du code.
- **Il lit et écrit deux tables déjà couvertes par des repositories.** `AlertRepository` sait déjà
filtrer par site. Le placer dans `ml/` obligerait à réécrire ces accès en SQL brut, et à
maintenir deux représentations du même domaine.
- **Le déclencheur HTTP n'a de sens que dans l'API.** `POST /recommendations/generate` doit passer
par `require_role(Role.ADMIN)` et par la session injectée : cela suppose d'être dans
l'application FastAPI.
Le moteur reste néanmoins **déclenchable hors HTTP**, par `python -m app.cli
generate-recommendations` (cible `make recommendations`), sur le patron de `make ml-score` : rien
n'oblige à exposer un port pour régénérer des recommandations.
## Conséquences
- L'API gagne sa première route d'écriture métier. La checklist de `20-backend.md` s'applique :
entrée dans `ROLE_MINIMUM` de `tests/api/acces.py`, et `openapi.json` régénéré dans le même
commit.
- `RecommendationService` n'est plus en lecture seule : il reçoit le `Transaction` Protocol déjà
utilisé par `AuthService` et `UserService`, et commite lui-même. Les repositories continuent de
ne pas commiter.
- **L'idempotence est déléguée à la base.** `create_missing()` insère en `ON CONFLICT DO NOTHING`
sur `uq_recommendation_alert_rule` plutôt que de relire avant d'écrire, ce qui supprime la
fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle.
Une règle dont le sens change prend une référence `-v2` ; renommer une référence livrée
ferait réapparaître ses recommandations à côté des anciennes.
- **Le moteur est branché sur la détection interne, et sur elle seule.** `alert` est alimentée
par `app/detection/internal_alerts.py` (#104), lancée à la main comme `enervision_ml.score` ;
l'ingestion de l'API Mock `/alerts` reste à faire. Le rapport de génération est donc à zéro tant
que la détection n'a pas tourné, sans que le moteur soit à retoucher.
- **L'insertion est découpée en lots.** `create_missing()` écrit par paquets de `TAILLE_DE_LOT`
lignes : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre colonnes,
et la détection interne peut alimenter `alert` au fil de l'eau.
- Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait
à rouvrir : le moteur redeviendrait consommateur du pipeline ML.
## Alternatives écartées
- **Module et CLI dans `ml/enervision_ml/`** : cohérent avec le label `ml` et avec la lettre de
l'ADR 0005, mais impose du SQL brut là où deux repositories existent, et laisse la génération
hors de portée de l'API. Redeviendrait le bon choix si les règles se mettaient à consommer des
features ou un modèle.
- **Génération à la volée, sans persistance**, calculée à chaque `GET /recommendations` : supprime
le besoin d'écriture, mais rend la table `recommendation` et sa contrainte d'unicité inutiles,
et interdit toute trace de ce qui a été proposé et quand.
- **Table de configuration des règles en base**, plutôt qu'un catalogue en Python : plus souple,
mais déplace la logique métier hors de la revue de code et hors des tests, pour un besoin que
rien n'exprime à ce stade.
@@ -1,120 +0,0 @@
# 0007 - Terminaison TLS par un reverse proxy Nginx, en Docker Compose
- Statut : accepté
- Date : 2026-09-21
## Contexte
Quatre documents désignaient le même trou. `10-infra.md` ouvrait ses questions par « Quel ingress
remplace Traefik, et qui termine le TLS ». `00-vue-ensemble.md` rangeait « TLS, HSTS et CSP » dans
« Absent, et assumé ». `owasp-traceabilite.md` laissait la ligne API8 transport ouverte.
`31-contrat-authentification.md` listait deux corrections « à faire avant la démonstration » :
servir le SPA et l'API sous la même origine, et servir en HTTPS.
Ce n'est pas un durcissement facultatif, c'est une condition de fonctionnement. Les deux fichiers
`apps/frontend/src/environments/environment*.ts` portent `apiUrl: '/api/v1'`, en relatif. En
développement, `proxy.conf.json` route `/api` vers l'API. Une fois en conteneur, plus rien ne le
fait : l'application déployée ne peut pas appeler son API. Et le cookie de rafraîchissement prend
le préfixe `__Secure-` dès que `APP_ENV` sort de `local`, donc sans HTTPS il n'est jamais posé et
l'authentification ne tient pas au rechargement de page.
La contrainte qui cadre tout le reste : **aucun nom de domaine public n'existe**. La cible
documentée est le serveur on-premise de l'école, `ssh_host = "10.0.0.10"` dans le
`terraform.tfvars.example`. Sur une adresse privée, le défi HTTP-01 de Let's Encrypt ne peut pas
aboutir, faute de DNS public et de port 80 entrant.
## Décision
**Un service `proxy` dans Docker Compose**, image officielle `nginx:1.28-alpine`, seul composant à
publier des ports sur la machine : 80 et 443. Backend et frontend ne sont plus publiés du tout, la
base et l'interface Mailpit sont ramenées sur la boucle locale. La stack complète est décrite par
l'overlay `docker-compose.prod.yml`, le `docker-compose.yml` restant la boucle de développement.
**Le SPA et l'API sont servis sous la même origine** : `/` vers le conteneur frontend, `/api/` vers
l'API en préservant le préfixe `/api/v1`. Le CORS cesse d'être un mécanisme de production et
redevient ce qu'il est, un filet pour les appels croisés qui ne devraient plus exister.
**nginx lit toujours les deux mêmes fichiers**, `/etc/nginx/tls/fullchain.pem` et `privkey.pem`.
Seule leur fabrication varie : un script `openssl` pour la démonstration, le `--deploy-hook` de
certbot quand un domaine existera. La configuration nginx ne connaît pas la différence et n'aura
pas à changer le jour de la bascule.
**Le proxy pose HSTS et CSP**, que l'application refuse de poser. Ce refus est verrouillé par
`tests/api/test_hardening.py::test_the_application_never_sets_hsts_itself` : l'application ne peut
pas savoir si elle est jointe en HTTPS, le terminateur, si.
## Pourquoi Compose et pas l'ingress k3s
Le module `infra/terraform/modules/k3s/` installe un cluster et rien d'autre. Il ne déclare que le
provider `null`, aucun namespace, aucun déploiement, aucun service, aucun ingress, et il n'a jamais
été appliqué. Passer par un ingress supposait d'abord de combler tout ce qui manque entre les deux
topologies : un registre d'images alimenté, des manifestes pour le front, l'API et la base, un
stockage persistant pour PostgreSQL. C'est le chantier que `10-infra.md` nomme « le trou entre les
deux topologies », et il ne tient pas dans le jalon.
Compose, lui, fait déjà tourner les quatre services sur un réseau commun. Le proxy y entre comme un
cinquième service, sans rien déplacer. La décision de désactiver Traefik reste valable : le choix
d'ingress n'est pas tranché ici, il est repoussé avec le reste de la bascule Kubernetes.
## Ce que le proxy n'expose pas, et pourquoi c'est structurel
`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**,
pas sous le préfixe `/api`. Avec un routage où seul `/api/` part vers l'API, ils tombent dans
`location /`, donc sur le SPA, donc hors d'atteinte publique. Aucune règle de blocage n'est
nécessaire, et il n'y en a pas : le jour où quelqu'un routera la racine vers l'API pour « réparer »
Swagger, il publiera les métriques avec.
## Conséquences
- `APP_ENV`, `APP_DEBUG`, `APP_CORS_ORIGINS`, `APP_TRUST_PROXY_HEADERS` et le TLS changent
ensemble, dans le même fichier. Hors `local`, la configuration refuse de démarrer sans origine
CORS, et le cookie devient `__Secure-ev_refresh`.
- `APP_TRUST_PROXY_HEADERS` passe à vrai, et le proxy écrit `X-Forwarded-For` avec
`$proxy_add_x_forwarded_for`, qui ajoute l'IP réelle en fin de chaîne. C'est exactement ce que
lit `get_client_ip()`. Toute autre forme ferait compter la limitation de débit par IP sur l'IP
du proxy, c'est-à-dire globalement.
- `--forwarded-allow-ips=*` reste sans conséquence : uvicorn s'en sert pour réécrire
`request.client` depuis `X-Forwarded-For`, et `get_client_ip()` est le seul lecteur de
`request.client` du backend, en dernier recours quand l'en-tête est absent.
- Une limitation de débit au frontal existe désormais, distincte de celle de l'application : 20
requêtes par seconde sur l'API, et 30 par minute sur les seules routes qui vérifient un secret,
`login`, `password`, `forgot-password` et `reset-password`. `/auth/me` et `/auth/refresh` en
sont exclues : elles partent à chaque chargement de page, et le NAT de l'école donnant une seule
adresse à toute la promotion, la zone resserrée les aurait transformées en 429 en démonstration.
- **La CSP contraint le build du frontend.** `script-src 'self'` interdit les gestionnaires
d'événements en ligne, et l'inlining du CSS critique d'Angular produisait exactement cela :
`<link rel="stylesheet" media="print" onload="this.media='all'">`. La feuille serait restée en
`media="print"`, donc l'application entière sans style. D'où `styles.inlineCritical: false` dans
`angular.json`. `style-src` garde `'unsafe-inline'`, dont Angular a besoin pour les styles de
composants injectés à l'exécution.
- **La redirection 80 vers 443 conserve `$host`.** Un client qui forge son en-tête `Host` obtient
donc une redirection vers l'hôte de son choix. Risque accepté : un navigateur ne peut pas être
amené à envoyer un `Host` étranger, aucun cache ne s'intercale, et figer un nom canonique
couperait l'accès par adresse IP, seule voie ouverte sur `10.0.0.10`.
- **Aucun `:?` dans l'overlay.** Compose interpole tout le fichier avant n'importe quelle
sous-commande : une garde y casserait `stop` et `logs` autant que `up`. `PUBLIC_HOST` retombe
donc sur `enervision.local`, et `make stack-up` vérifie à la place que le certificat présent
couvre l'hôte demandé, ce qui est la condition réelle à tenir.
- Le proxy attend une API saine et pas seulement démarrée : le `HEALTHCHECK` de l'image du backend
sert de condition à `depends_on`, faute de quoi les premiers appels à `/api/` répondent 502.
- La ligne API8 transport de `owasp-traceabilite.md` se referme.
- **Let's Encrypt n'est pas prouvé.** Le chemin ACME est livré, monté et documenté ; il n'a pas
été exercé faute de domaine. Le certificat de démonstration est auto-signé, le navigateur
avertit, et c'est la situation réelle du projet, pas un raccourci.
- Le proxy résout ses cibles par le résolveur interne de Docker plutôt que par un bloc `upstream`,
sans quoi recréer le seul conteneur backend suffirait à produire des 502 jusqu'au rechargement.
## Alternatives écartées
- **Ingress k3s avec cert-manager** : la bonne cible, et elle reste la cible. Elle suppose un
registre et des manifestes qui n'existent pas, à quatre jours du rendu.
- **Étendre le `nginx.conf` du conteneur frontend** avec un `location /api` et l'écoute TLS :
moins de pièces, mais les certificats entrent dans l'image du front et tout rebuild du front
redéploie le terminateur TLS. La séparation des cycles de vie vaut le conteneur supplémentaire.
- **Traefik ou Caddy**, qui automatisent ACME : ils déplacent le problème sans le résoudre, le
défi HTTP-01 échouant pour la même raison. Et l'issue nomme Nginx.
- **Let's Encrypt par défi DNS-01** : fonctionne derrière une IP privée, mais exige un domaine
possédé et un jeton d'API chez le fournisseur DNS. Rouvrable sans rien changer à la
configuration nginx le jour où ces deux éléments existent.
- **Un `Dockerfile` de proxy** : inutile, la configuration est montée en volume. Cela évite aussi
la dépendance à un registre authentifié, piège déjà présent dans `apps/frontend/Dockerfile`.
@@ -1,79 +0,0 @@
# 0008 - Airflow exécute le code du backend en sous-processus
- Statut : accepté
- Date : 2026-09-21
## Contexte
L'issue #116 demande un DAG d'alertes. Ce qu'il a à ordonnancer existe déjà et n'est pas à
réécrire : `AlertService.detect()` et ses cinq règles (#104), puis le moteur de recommandations
(#38). Les deux vivent dans `apps/backend/app/`, et
l'[ADR 0006](0006-moteur-de-regles-dans-le-backend.md) a précisément décidé qu'ils y restent parce
qu'ils s'appuient sur les repositories ORM de l'API plutôt que sur du SQL brut. Les deux
sont décrits par la documentation comme « lancés à la main ».
L'image Airflow livrée par #115 ne porte que `ml/`, dans un environnement `uv` distinct
(`/opt/ml/.venv`, Python 3.14) de celui d'Airflow lui-même (Python 3.12, contraint par
apache-airflow 2.10). Les DAGs `ml_train` et `ml_score` shellent vers cet environnement. Rien
d'équivalent n'existe pour `apps/backend` : un `BashOperator` sur
`python -m app.detection.internal_alerts` échouerait en `ModuleNotFoundError`.
## Décision
**L'image Airflow porte un troisième environnement, `/opt/backend/.venv`**, construit depuis le
`pyproject.toml`, le `uv.lock` et le paquet `app/` du backend. Le DAG `alertes` shelle vers lui
exactement comme `ml_score` shelle vers `/opt/ml/.venv`.
Trois raisons :
- **Le patron existe et vient d'être revu.** #115 a posé `BashOperator` + `uv run --no-sync` +
`env -u VIRTUAL_ENV`, avec les tests d'intégrité qui le verrouillent. Introduire une seconde
forme d'appel dans le même dossier `dags/` coûterait plus cher à lire qu'un second environnement
dans le même `Dockerfile`.
- **Aucune surface réseau n'est ajoutée.** La détection n'a pas de route HTTP, contrairement à la
génération de recommandations (`POST /recommendations/generate`, rôle `admin`). En créer une pour
qu'Airflow l'appelle donnerait à l'ordonnanceur un compte administrateur de l'API, en plus des
identifiants PostgreSQL complets qu'il détient déjà, et ferait dépendre la production d'alertes
de la disponibilité du conteneur `backend`.
- **La logique reste où l'ADR 0006 l'a mise.** Le DAG n'apprend rien du domaine : ni les seuils, ni
les cinq règles, ni les clés d'idempotence. Il ne sait que l'heure à laquelle appeler.
## Conséquences
- **Airflow reçoit une `APP_SECRET_KEY` délibérément distincte de celle de l'API.** La
configuration du backend refuse de se construire sans elle (`app/core/config.py`), et
`internal_alerts.main()` appelle `get_settings()` avant toute requête pour échouer tôt. Mais la
détection ne signe ni ne vérifie aucun jeton, et Airflow permet d'exécuter du code arbitraire
depuis son interface : un Airflow compromis ne doit pas livrer la clé de signature des JWT. D'où
`AIRFLOW_APP_SECRET_KEY`, avec sa propre garde dans `airflow-init`.
- **`DATABASE_URL`, en dialecte asyncpg, rejoint `ML_DATABASE_URL`** dans l'environnement du
conteneur. Le cantonnement des rôles PostgreSQL reste la dette de
l'[ADR 0003](0003-autorisation-rbac-a-trois-roles.md), et cette décision l'alourdit d'un
consommateur de plus.
- **La CI Airflow se déclenche sur les changements du backend.** L'image le `COPY` : sans
`apps/backend/app/**`, `pyproject.toml` et `uv.lock` dans les déclencheurs du workflow, une
dépendance modifiée casserait la construction sans que rien ne le signale avant le déploiement.
En contrepartie, l'image grossit de ce que pèsent SQLAlchemy, asyncpg et pandas.
- **Aucune variable ne départage les deux environnements, et c'est voulu.** `uv` place par défaut
le venv d'un projet dans `<projet>/.venv` : `cd /opt/ml` ou `cd /opt/backend` suffit à choisir le
bon. L'image ne pose donc plus de `UV_PROJECT_ENVIRONMENT` global, hérité de #115 : il vaudrait
pour les deux projets, et `uv run` dans l'un résoudrait le venv de l'autre. Le symptôme n'est pas
une construction ratée mais un `ModuleNotFoundError` à la première tâche, d'où la vérification
d'import sans réseau que la CI fait maintenant sur chacun des deux.
- Airflow lui-même reste étranger au domaine : ni LightGBM, ni SQLAlchemy, ni FastAPI n'entrent
dans son interpréteur. C'est la propriété que #115 avait établie, et elle tient toujours.
## Alternatives écartées
- **Route HTTP `POST /alerts/detect` réservée `admin`, appelée par le DAG.** L'image ne bougeait
pas, mais Airflow détenait alors un compte administrateur de l'API, la détection devenait
tributaire du conteneur `backend`, et l'API gagnait une route d'écriture dont aucun client
humain n'a l'usage. À rouvrir si un jour un tiers doit déclencher la détection.
- **`DockerOperator` lançant l'image du backend.** Demande la socket Docker de l'hôte dans le
conteneur Airflow, c'est-à-dire un équivalent root sur la machine, pour un service qui permet
déjà d'exécuter du code depuis son interface. Le provider n'est d'ailleurs pas installé.
- **Réécrire les cinq règles en SQL dans le DAG.** Contredit frontalement l'ADR 0006, duplique le
domaine, et fait diverger les deux copies au premier changement de seuil.
- **Monter `apps/backend` en volume plutôt que le copier.** L'environnement ne serait plus figé à
la construction, `uv` resynchroniserait au premier lancement, et la CI ne prouverait plus rien
de ce qui tourne réellement.
+16 -37
View File
@@ -46,7 +46,6 @@ flowchart TB
navigateur["Navigateur"]
subgraph machine["Machine on-premise"]
proxy["Reverse proxy Nginx<br/>:80 et :443"]
front["Frontend Angular 22<br/>apps/frontend"]
api["API FastAPI<br/>apps/backend"]
db[("PostgreSQL 17<br/>TimescaleDB")]
@@ -55,12 +54,10 @@ flowchart TB
grafana["Grafana"]
end
navigateur --> proxy
proxy --> front
proxy --> api
navigateur --> front
front -.-> api
api --> db
airflow --> db
airflow -.-> db
prom -.-> api
grafana -.-> db
grafana -.-> prom
@@ -70,11 +67,6 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
[30-frontend.md](30-frontend.md).
Le lien `airflow --> db` est maintenant en trait plein : trois DAGs tournent, deux pour
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
génération des recommandations (issue #116), cf. plus bas et [20-backend.md](20-backend.md). Le
reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit.
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
collecteur ne vient le lire.
@@ -82,21 +74,18 @@ collecteur ne vient le lire.
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|---|---|---|---|---|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary` et `readings` en lecture (endpoints → services → repositories → models) |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement (features par lags/moyennes glissantes, baseline de persistance saisonnière, suivi MLflow local), voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Scoring, endpoint et surveillance de dérive pas encore construits |
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 5 workflows, 16 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) |
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
## Flux bout en bout
Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin
d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`,
issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas
l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16).
Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base.
```mermaid
sequenceDiagram
@@ -148,11 +137,6 @@ consolidée.
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
- **Terminaison TLS au frontal** : un reverse proxy Nginx est le seul service publié, il redirige
80 vers 443, sert le SPA et l'API sous la même origine, pose **HSTS** et **CSP** que
l'application refuse délibérément de poser, et ajoute une **limitation de débit au frontal**
distincte de celle de l'application. Voir
[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
- **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root`
sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les
`.example`.
@@ -166,12 +150,13 @@ consolidée.
arrêteraient une application compromise. Même raison de report.
- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut
agir sur le site B. C'est la limite connue du modèle.
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME
est livré et documenté mais pas exercé.
- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place
(`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir
[50-cicd.md](50-cicd.md).
- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore.
- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas
l'infrastructure.
- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD.
- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP
simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir
[31-contrat-authentification.md](31-contrat-authentification.md).
## Décisions structurantes
@@ -180,9 +165,3 @@ Elles vivent dans `../adr/`, pas ici.
| ADR | Objet |
|---|---|
| [0001](../adr/0001-postgresql-timescaledb.md) | PostgreSQL 17 avec l'extension TimescaleDB, et la frontière `db/` vs `alembic/` |
| [0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
| [0003](../adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête |
| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
+8 -139
View File
@@ -1,13 +1,12 @@
# Infrastructure
Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.
Deux topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre les deux.
| Topologie | Sert à | Statut |
|---|---|---|
| Docker Compose | Développer et recetter sur le poste | `Fait` |
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
| k3s single-node | Cible à terme | `En cours` |
| k3s single-node | Déployer sur le serveur on-premise | `En cours` |
## Poste de développement
@@ -41,135 +40,15 @@ seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste ave
des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le
port 8000, ils ne se lancent donc pas ensemble.
Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
- `PGDATA` vaut `/home/postgres/pgdata/data` pour l'image `-ha`, et non le chemin habituel de
l'image `postgres`. Monté ailleurs, le volume ne retient rien, sans le moindre message.
- `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
le compose. Voir [`db/README.md`](../../db/README.md).
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver :
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
### Airflow (issues #115 et #116)
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
airflow-up`, pas dans `make dev`) :
| Service | Rôle | Points notables |
|---|---|---|
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès |
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même |
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` |
Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) :
l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser
deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à
la construction), distincts du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent
vers ces venvs plutôt que d'importer LightGBM, MLflow ou SQLAlchemy dans le process Airflow.
Le choix et ses contreparties sont dans
l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
| DAG | Planification | Ce qu'il lance, et où |
|---|---|---|
| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` |
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni
tâche greffée : quatre règles de détection sur cinq ne touchent pas au modèle, et un modèle jamais
entraîné ne doit pas priver le parc de ses alertes. Le décalage est donc une convention et non une
garantie : le plafond de `ml_score` est de 30 minutes, et un scoring qui déborde de `:15` prive
`anomaly` de la `prediction` de l'heure, qu'elle ne retrouvera au passage suivant que si sa fenêtre
la couvre encore. Les quatre autres règles ne s'en aperçoivent pas.
Ses deux tâches s'enchaînent en revanche (`recommendation.alert_id` est une clé étrangère `NOT
NULL`), et toutes deux sont rejouables sans risque : l'idempotence est portée par la base,
`uq_alert_source_reference` et `uq_recommendation_alert_rule`. Chacune a 2 tentatives, 2 minutes
d'attente entre elles et un plafond de 5 minutes **par tentative** : au pire, reprises comprises,
l'enchaînement occupe 38 minutes, ce qui le garde sous le pas horaire qu'un `max_active_runs=1`
rend contraignant.
`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`,
`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une
migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et
`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier
entier avant de filtrer les services, une variable requise manquante casserait `make db-up`,
`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est
`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou
`AIRFLOW_APP_SECRET_KEY` vides).
Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en
dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est
**délibérément différente** de celle de l'API. La configuration du backend refuse de se construire
sans clé, mais la détection ne signe ni ne vérifie aucun jeton : un Airflow compromis, qui permet
déjà d'exécuter du code depuis son interface, ne doit pas livrer par-dessus la clé de signature
des JWT.
**Pourquoi `ml_train` est manuel.** Réentraîner est coûteux et sa cadence n'est pas une décision
prise. Surtout, `train.py` écrase le modèle sans comparer ses métriques à celles de l'ancien : un
cron déploierait silencieusement un modèle dégradé. Tant que ce garde-fou n'existe pas, le
déclenchement reste humain. `ml_score`, lui, est planifié à l'heure, avec `max_active_runs=1`
(pas deux scorings simultanés dans `prediction`), 2 tentatives et un plafond de 30 minutes.
CI : `.github/workflows/airflow.yml` (Python 3.12 via `etl/airflow/.python-version`) lance lint et
tests d'intégrité des DAGs, et construit l'image (elle `COPY` `ml/` et `apps/backend/`, une
modification de l'un ou de l'autre peut donc la casser, d'où leurs chemins dans les déclencheurs)
avant de vérifier que les deux environnements s'y importent sans réseau.
Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier
`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute
`docker-entrypoint-initdb.d/` que sur un volume vide). Créer la base `airflow` à la main une fois :
`docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE DATABASE airflow;"`.
`libgomp1` est installé explicitement dans l'image (`apt-get`, en root) : l'image Airflow de base
est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans quoi l'erreur
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
construction de l'image.
## Machine cible, exécution Docker
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
`docker-compose.yml`. Écrit et validé sur le poste, **jamais encore lancé sur le serveur de
l'école**. Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
```mermaid
flowchart LR
navigateur["Navigateur"]
subgraph machine["Machine on-premise"]
proxy["service proxy<br/>nginx:1.28-alpine<br/>:80 et :443"]
front["service frontend<br/>nginx statique :3000"]
api["service backend<br/>uvicorn :8000"]
db[("service db<br/>:5432")]
mail["service mailpit"]
end
navigateur -->|"HTTPS"| proxy
proxy -->|"/"| front
proxy -->|"/api/"| api
api --> db
api --> mail
```
Le proxy est **le seul service à publier des ports** sur le réseau. Backend et frontend ne sont
plus publiés du tout, la base et l'interface Mailpit sont ramenées sur `127.0.0.1`, donc joignables
par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et
la commande de validation hors exécution sont dans [`infra/proxy/README.md`](../../infra/proxy/README.md).
Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent pas :
- Servir le SPA et l'API sous la même origine est ce qui rend le cookie `__Secure-ev_refresh`
utilisable. Sans cela, `apiUrl: '/api/v1'` ne mène nulle part une fois en conteneur.
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
compte sur l'IP du proxy et devient globale.
## Cible à terme, k3s
## Cible de déploiement
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
été appliqué.
@@ -225,8 +104,6 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` |
| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` |
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
## Ports et noms
@@ -235,16 +112,12 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| PostgreSQL, côté hôte | `5433` | Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris |
| PostgreSQL, côté réseau Compose | `db:5432` | Nom de service, utilisé par `DATABASE_URL` du service `backend` |
| API | `8000` | Identique en conteneur et hors conteneur |
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`. 80 ne sert que la redirection et le défi ACME |
| Frontend, `ng serve` | `4200` | Valeur par défaut d'`APP_CORS_ORIGINS`. Le compose n'a aucun service frontend |
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
## Le trou vers k3s
## Le trou entre les deux topologies
Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose
construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première
@@ -252,11 +125,7 @@ question à trancher, avant toute ressource Kubernetes.
## Questions ouvertes
- **Quel ingress** remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par
l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), mais la réponse vaut pour la
topologie Compose, pas pour Kubernetes.
- **Quel nom de domaine public**, sans lequel Let's Encrypt reste hors d'atteinte et le certificat
reste auto-signé.
- **Quel ingress** remplace Traefik, et qui termine le TLS.
- **Quel registre d'images**, et comment il est alimenté sans CI.
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
cluster ou à côté.
+7 -86
View File
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors, predictions"]
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, sensors"]
sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, ReadingService, SensorService, PredictionService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading, prediction"]
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, SensorService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading"]
md["models<br/>10 tables"]
db[("PostgreSQL")]
@@ -146,11 +146,9 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 |
| GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 |
| POST | `/api/v1/recommendations/generate` | Applique le moteur de règles aux alertes, filtrable par `site_id`. `admin` | 401, 403, 422, 500 |
| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 |
| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 |
| GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 |
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
@@ -165,7 +163,7 @@ le jeton à usage unique plutôt que dans un `Principal`.
donc de modifier `ROUTES_PUBLIQUES` dans `tests/api/acces.py`.
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour
`GET /alerts` puis pour les suivantes (`dataset`) : les quatre couches
`GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches
`endpoints → services → repositories → models` y sont toutes présentes, sur des tables déjà créées
par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux
routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une
@@ -183,33 +181,6 @@ dernière `Reading` du site : un site connu sans lecture rend `200` avec tous le
détaillé pour le frontend est dans
[31-contrat-authentification.md](31-contrat-authentification.md).
`GET /predictions` reprend ce même sous-gabarit « dernière valeur par site » (`SiteRepository` +
`PredictionRepository`, un `SitePredictionSummaryResponse` par site plutôt qu'une table brute).
Différence avec `stats`/`sensors` : `prediction` est une vraie table accumulée par un processus
externe (`enervision_ml.score`, cf. `ml/README.md`), pas une valeur recalculée à la volée depuis
`reading` à chaque appel. `PredictionRepository.latest_by_site()` isole donc un `DISTINCT ON
(site_id)` ordonné par `target_at DESC` (couvert par l'index `ix_prediction_site_target`), le même
mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend `prediction: null`
plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte
`ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais
LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf.
[ML-START.md](../ML-START.md) section 3.
`POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique
le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni
feature ML : le catalogue `REGLES` associe à chaque type et à chaque gravité d'alerte une action et
son explication, et une même alerte peut en déclencher plusieurs, comme le prévoit
[40-data.md](40-data.md). L'idempotence est portée par la base, pas par le service :
`RecommendationRepository.create_missing()` insère en `ON CONFLICT DO NOTHING` sur
`uq_recommendation_alert_rule`, donc rejouer la génération sur les mêmes alertes ne crée rien et
le rapport rendu distingue `recommendations_created` de `already_present`. Le même traitement est
disponible hors HTTP par `python -m app.cli generate-recommendations` (cible `make
recommendations`), sur le patron de `make ml-score`. Le choix de loger le moteur dans le backend
plutôt que dans `ml/` est justifié par l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md).
Les alertes traitées sont celles qu'écrit la détection interne (#104, section ci-dessous) : la
génération ne rend donc de recommandations qu'une fois la détection passée. L'insertion est
découpée en lots de `TAILLE_DE_LOT` lignes, asyncpg plafonnant une requête à 32 767 paramètres.
`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable,
donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/
owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni
@@ -223,53 +194,6 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est
l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait
côté pilote, en `500` plutôt qu'un refus propre.
### Détection d'alertes internes
`AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour
le type `anomaly`, les `prediction`) des dernières 48h (`LOOKBACK`) à cinq règles et enregistre une
ligne `alert` par déclenchement, avec `source="enervision"`. `metric`/`value`/`threshold` gardent
leur sens dans chaque règle plutôt que d'être laissés à `null` par commodité :
| `type` | Règle | `value` / `threshold` |
|---|---|---|
| `threshold` | `reading.consumption_kw` dépasse `site.capacity_kw` (site sans capacité déclarée : ignoré) | mesure / capacité du site |
| `spike` | Variation relative ≥ 50% (`SPIKE_RELATIVE_THRESHOLD`) entre deux lectures consécutives du même site, ou redémarrage direct à une valeur positive depuis zéro (`critical`) | mesure actuelle / mesure précédente |
| `anomaly` | Écart relatif ≥ 30% (`ANOMALY_RELATIVE_THRESHOLD`) entre `reading.consumption_kwh` et la `prediction` du même site dont `target_at == timestamp` | mesure réelle / valeur prédite |
| `outage` | Aucune lecture depuis plus de 3h (`OUTAGE_THRESHOLD`, 3x la cadence horaire nominale), ou site jamais lu | `null` / `null` |
| `sensor` | `reading.data_quality` ∈ `partial`/`degraded`/`critical` | `null` / `null` |
La sévérité de chaque alerte (hors `sensor`, dérivée directement de `data_quality`) suit le même
barème par ratio observé/seuil : `low` sous 1.2, `medium` sous 1.5, `high` sous 2.0, `critical`
au-delà. `AlertRepository.create_many()` insère par lot avec `ON CONFLICT DO NOTHING` sur
`uq_alert_source_reference`, et `source_alert_id` est construit de façon déterministe (règle +
horodatage) : rejouer la détection sur une fenêtre déjà analysée ne duplique donc jamais une
alerte.
**Pièges de tri corrigés en revue** : `reading`/`prediction` n'ont pas d'unicité sur leur couple
métier (`uq_reading_source` autorise deux `source` différentes au même `site_id`+`timestamp`,
`prediction` n'a aucune contrainte sur `(site_id, target_at)`, chaque run de scoring gardant sa
propre ligne). `ReadingRepository.list_since()`/`PredictionRepository.list_since()` départagent
donc les égalités par `reading_id`/`prediction_id` croissant, comme le font déjà
`latest_by_site()`/`latest_for_site()` sur les mêmes tables ; sans ce départage, l'ordre entre
lignes à égalité n'est pas garanti d'un appel à l'autre, et `_detect_spike`/`_detect_anomaly`
auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spike` ignore en plus
explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul
instant réel, pas une variation).
La détection s'exécute dans `apps/backend`, puisque les règles s'appuient sur les repositories ORM
de l'API plutôt que sur une connexion SQL directe (contrairement à
`app/etl/historical_import.py`) : `uv run python -m app.detection.internal_alerts [--site-id ...]
[--now ...]`, ou `make detect-alerts`. Cette issue (#104) débloquait #38 (moteur de règles pour
recommandations), dont la FK `alert_id` `NOT NULL` n'avait jusqu'ici rien à référencer côté
`source="enervision"`.
Depuis l'issue #116, le lancement n'est plus manuel : le DAG Airflow `alertes` enchaîne cette
détection et la génération des recommandations, toutes les heures à la quinzième minute. Airflow
exécute le code du backend en sous-processus, dans son propre environnement, ce que décide
l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md) ; le détail de l'ordonnancement est
dans [10-infra.md](10-infra.md). La ligne de commande reste le moyen de rejouer une fenêtre
passée, ce que `--now` permet et que le DAG ne fait pas.
### `/health/ready`
Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un
@@ -345,7 +269,7 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv
### Ajouter une route métier
Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/
`readings`/`sensors`/`predictions` (`dataset`) :
`readings`/`sensors` (`dataset`, `prediction`) :
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN`
au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux
@@ -397,12 +321,9 @@ Le reste, par ordre de surface :
de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL.
- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`,
`Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au
terminateur TLS, que l'application ne connaît pas : le reverse proxy les pose
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)).
terminateur TLS, que l'application ne connaît pas.
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
- TLS, limitation de débit au frontal et journal d'accès sont portés par le reverse proxy.
`APP_TRUST_PROXY_HEADERS` doit alors valoir vrai, sinon le compteur par IP devient global.
- Pas de journalisation des accès applicative.
- Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative.
## Observabilité
+25 -39
View File
@@ -13,29 +13,24 @@ Ce qui est en place :
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
`provideHttpClient(withInterceptors([mockApiInterceptor]))`.
- Une route `/dashboard` en composant différé, et une redirection depuis la racine.
- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et
`AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification
(jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`,
`features/dashboard` la page principale, `shared/components` la jauge de consommation et le
- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de
fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le
graphique de charge par site, tous deux construits sur Chart.js.
- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation,
changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail :
[31-contrat-authentification.md](31-contrat-authentification.md).
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
`ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit
réutiliser plutôt que redéfinir ses propres styles. Détail :
[32-design-systeme-frontend.md](32-design-systeme-frontend.md).
- L'état vit dans des signaux, sans bibliothèque dédiée.
- Vitest via le builder `@angular/build:unit-test`, couverture activée.
- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test.
- Prettier configuré, parser `angular` pour les gabarits HTML.
Ce qui n'existe pas encore :
- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien
ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement.
`GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette
PR (voir plus bas).
- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre.
- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que
les routes métier de l'API en exigent un. Voir
[31-contrat-authentification.md](31-contrat-authentification.md).
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
- Aucun lint : ESLint n'est pas installé.
@@ -89,19 +84,19 @@ sequenceDiagram
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il
suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide
et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche.
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
`apiUrl` relatif, `/api/v1`.
En production, `environment.ts` porte lui aussi un `apiUrl` relatif (`/api/v1`) plutôt qu'une URL
absolue : la dette qui pointait en dur sur `http://localhost:8000/api/v1` a été corrigée. Un build
de production sert donc l'appel `/api/v1/...` sur son propre origin, et c'est le **reverse proxy**
qui route `/api` vers le backend : `location /api/` dans `infra/proxy/conf.d/enervision.conf`, voir
[10-infra.md](10-infra.md) et l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
En production, il n'y a pas de proxy : `environment.ts` porte une URL absolue. Angular substitue
le fichier via `fileReplacements`, et la configuration `production` est celle par défaut.
**Dette connue.** `src/environments/environment.ts`, qui est la configuration de production,
pointe `http://localhost:8000/api/v1` en dur. La valeur est celle du poste de développement :
telle quelle, un build de production ne joindra jamais l'API. À corriger avant le premier
déploiement, en même temps que sera tranchée la question de l'ingress dans
[10-infra.md](10-infra.md).
## Exécution
@@ -118,29 +113,20 @@ le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.1
tester ni construire le frontend.
Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`,
englobées par `install` et `dev`). En développement il tourne directement via `npm`, depuis
`apps/frontend` : le port 4200 n'apparaît dans le compose que comme valeur par défaut
d'`APP_CORS_ORIGINS`, côté backend.
englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en
développement il tourne toujours directement via `npm`, depuis `apps/frontend`. Le port 4200
n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend.
Le service `frontend` du `docker-compose.yml` sert le build statique par le nginx de
`apps/frontend/Dockerfile`, multi-étage, qui **écoute sur 3000**. En déploiement il n'est plus
publié du tout : le reverse proxy est seul à sortir sur le réseau, et l'atteint par le réseau
Compose.
Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans
`CMD` : il construit sans rien servir. Le `README.md` de l'application demande un multi-étage
avec un service statique, il reste à écrire.
## Sécurité
- Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL.
- L'authentification existe des deux côtés désormais : `authGuard` protège `/dashboard` et
`/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
rafraîchissement sur 401. Détail complet dans
[31-contrat-authentification.md](31-contrat-authentification.md).
- **La CSP posée par le reverse proxy contraint le build.** `script-src 'self'` interdit les
gestionnaires d'événements en ligne ; l'inlining du CSS critique en produisait un
(`<link media="print" onload="this.media='all'">`), ce qui aurait laissé l'application sans
style derrière le proxy. D'où `optimization.styles.inlineCritical: false` dans la configuration
de production d'`angular.json`. La contrepartie est un rendu non stylé très bref au premier
affichage. `style-src` conserve `'unsafe-inline'` : Angular injecte les styles de composants à
l'exécution, et s'en passer demanderait un `ngCspNonce` que le SPA statique ne peut pas produire.
- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun
intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui
que les fixtures.
## Tests
@@ -129,18 +129,18 @@ n'est pas envoyé et le rafraîchissement échoue toujours.
En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est
**même origine** et le cookie marche sans rien configurer.
En déploiement, les deux conditions sont désormais remplies par le reverse proxy
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)) : `environment.ts` porte un
`apiUrl` relatif, `/api/v1`, et le proxy sert le SPA sur `/` et l'API sur `/api/` **sous la même
origine, en HTTPS**. C'est cela, et rien d'autre, qui rend le cookie `__Secure-ev_refresh`
utilisable : servi en HTTP simple ou depuis une autre origine, il n'est jamais posé et
l'authentification ne survit pas à un rechargement de page.
En production, `src/environments/environment.ts` contient encore le gabarit
`http://localhost:8000/api/v1`, en HTTP simple et sur une autre origine. **Dans cet état, aucun
cookie `Secure` ne sera posé et l'authentification ne fonctionnera pas.**
Ce qui reste à surveiller : le certificat est auto-signé tant qu'aucun domaine public ne résout
vers la machine. Un navigateur qui refuse l'exception refusera aussi le cookie.
Deux corrections, à faire avant la démonstration :
1. passer `apiUrl` à `/api/v1` et servir le SPA et l'API sous la même origine, via un
`location /api` dans le `nginx.conf` du conteneur frontend ou via l'ingress ;
2. servir en HTTPS.
Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que la même origine masque.
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que le proxy masque.
## Origines autorisées
+53 -359
View File
@@ -6,18 +6,10 @@ système qui en découle.
## Ce que couvre ce document
**Douze tables applicatives existent** : six pour l'authentification et six pour les données
d'énergie, dont l'hypertable `reading`.
Les sections marquées `Fait` relèvent du code déjà implémenté. Les sections marquées `Cible`
décrivent les éléments prévus mais pas encore réalisés.
L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le dataset CSV/JSON et
l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir
l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). Les alertes `source='enervision'`,
elles, sont produites par la détection interne, désormais ordonnancée par le DAG Airflow `alertes`
(issue #116). L'orchestration de l'ingestion, les agrégats continus, la compression et la
rétention restent des cibles.
**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données
d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles
marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne
d'ingestion, les agrégats continus, la compression et la rétention.
## Trois emplacements, trois rôles
@@ -43,16 +35,8 @@ Statut : `Fait`.
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
`apps/backend/tests/conftest.py`.
- Six révisions Alembic sont actuellement appliquées.
- La première, `5353c0e4f094`, **ne crée aucune table** : elle établit `alembic_version`
et refuse de s'appliquer si l'extension TimescaleDB manque.
- Les révisions suivantes créent les tables liées à l'authentification :
`app_user`, `login_attempt`, `audit_log` et `refresh_token`.
- La révision `e6d2026091501` crée les six tables Data et déclare l'hypertable `reading`.
- La révision `c0adab96238c` ajoute les tables `password_reset_attempt`
et `password_reset_token`.
La garde de la première migration est :
- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
```sql
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
@@ -63,58 +47,37 @@ END IF;
Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée
les six tables de données décrites en fin de document et déclare l'hypertable `reading`.
## Cycle de vie d'une mesure
Statut : `Partiellement fait`.
Les mécanismes d'ingestion sont maintenant implémentés pour les deux sources de données du MVP :
- le dataset historique CSV/JSON avec `historical_import.py` ;
- l'API Mock avec `mock_api_import.py`.
Les traitements sont actuellement exécutables directement depuis le backend.
L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus,
la compression et les politiques de rétention.
Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats
continus, ni la compression, ni la rétention ne sont écrits.
```mermaid
flowchart LR
csv["CSV + JSON"] --> hist["historical_import.py"]
mock["API Mock"] --> api["mock_api_import.py"]
hist --> hy[("Hypertable reading")]
api --> hy
airflow["Airflow"] -.-> hist
airflow -.-> api
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
ing -.-> hy[("Hypertable reading")]
hy -.-> agg[("Agrégat continu")]
hy -.-> comp["Compression"]
hy -.-> ret["Rétention"]
agg -.-> backend["API FastAPI"]
agg -.-> api["API FastAPI"]
agg -.-> graf["Grafana"]
```
Les flèches pleines représentent les traitements actuellement implémentés.
Les flèches pointillées représentent les éléments encore prévus comme cibles.
Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout
Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout
l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront.
## Tables d'authentification
Statut : `Fait`.
Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les hypertables ;
elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API.
Statut : `Fait`. Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les
hypertables ; elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API.
```mermaid
erDiagram
APP_USER ||--o{ REFRESH_TOKEN : ouvre
APP_USER ||--o{ PASSWORD_RESET_TOKEN : recoit
APP_USER {
uuid id PK
string email UK
@@ -125,7 +88,6 @@ erDiagram
bool must_change_password
timestamptz credentials_changed_at
}
REFRESH_TOKEN {
uuid id PK
uuid family_id
@@ -137,7 +99,6 @@ erDiagram
text revoked_reason
uuid replaced_by
}
LOGIN_ATTEMPT {
bigint id PK
timestamptz occurred_at
@@ -145,7 +106,6 @@ erDiagram
inet client_ip
text outcome
}
AUDIT_LOG {
bigint id PK
timestamptz occurred_at
@@ -154,27 +114,9 @@ erDiagram
text action
jsonb detail
}
PASSWORD_RESET_ATTEMPT {
bigint id PK
timestamptz occurred_at
string email_tried
inet client_ip
}
PASSWORD_RESET_TOKEN {
uuid id PK
uuid user_id FK
bytea token_hash UK
timestamptz issued_at
timestamptz expires_at
timestamptz consumed_at
inet client_ip
text user_agent
}
```
Six choix de modélisation portent une intention et se défendent seuls :
Quatre choix de modélisation portent une intention et se défendent seuls :
- **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de
`CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition
@@ -187,10 +129,6 @@ Six choix de modélisation portent une intention et se défendent seuls :
- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont
dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur
d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md).
- **`password_reset_token` ne stocke que l'empreinte du jeton**, jamais sa valeur. Une fuite de
la table ne donne donc rien à rejouer.
- **`password_reset_attempt` est séparée de `audit_log`** : son volume est piloté par le
demandeur, comme celui de `login_attempt`, donc elle doit pouvoir se purger.
`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est
donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils
@@ -199,9 +137,8 @@ piloté par l'attaquant.
## Gabarit de révision créant une hypertable
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision.
La révision `e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision
`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
```python
def upgrade() -> None:
@@ -245,29 +182,24 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà.
## Questions ouvertes
Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma et l'ingestion sont
livrés : ce qui suit porte sur leur exploitation, plus sur leur forme.
Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit
porte sur son exploitation, plus sur sa forme.
- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure.
- **Quels agrégats continus** créer et sur quelles fenêtres.
- **Quelle profondeur de rétention** conserver en données brutes et à partir de quand compresser.
- **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures.
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
- **Quels agrégats continus**, et sur quelles fenêtres.
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
## Modélisation détaillée des données
Cette modélisation prend en compte :
- les fichiers CSV historiques ;
- leurs métadonnées JSON ;
- les données de l'API Mock.
Elle comprend six tables Data, depuis le stockage des mesures jusqu'aux recommandations proposées
à l'utilisateur.
Cette modélisation prend en compte les fichiers CSV historiques,
leurs métadonnées JSON et les données de l’API Mock.
Elle comprend six tables, depuis le stockage des mesures
jusqu’aux recommandations proposées à l’utilisateur.
### Schéma de données
Le diagramme ci-dessous présente les tables et leurs relations.
La révision `e6d2026091501` les crée.
![Schéma de données EnerVision](images/EnerVision-schema-donnees.png)
@@ -276,26 +208,21 @@ La révision `e6d2026091501` les crée.
### Description des tables
Chaque table remplit un rôle précis dans le traitement et l'exploitation des données.
Chaque table remplit un rôle précis dans le traitement et l’exploitation
des données.
| Table | Rôle | Origine des informations |
|---|---|---|
| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l'import |
| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
| `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
| `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision |
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision |
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`.
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
Les lignes de `recommendation` sont écrites par le moteur de règles du backend
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`,
par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en
base. Le couple `(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes
n'ajoute aucune ligne.
Les anomalies historiques décrites dans les JSON sont conservées
dans `dataset.metadata`. Elles servent à l’analyse des données
et ne sont pas considérées comme des alertes actuelles.
### Relations entre les tables
@@ -307,19 +234,13 @@ n'ajoute aucune ligne.
## Ingestion des données historiques
Statut : `Fait`.
Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre du projet.
Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre
du projet.
Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période du 1er janvier 2023 au 31 décembre 2024.
Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période
du 1er janvier 2023 au 31 décembre 2024.
Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données.
Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
### Architecture du flux historique
### Architecture du flux
```text
Dataset CSV + métadonnées JSON
@@ -350,26 +271,17 @@ Dataset CSV + métadonnées JSON
Le pipeline est développé en Python.
Pandas est utilisé pour l'extraction, la validation et la préparation des données.
SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB.
Pandas est utilisé pour l'extraction, la validation et la préparation des données. SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB.
Une empreinte SHA-256 permet d'identifier le dataset utilisé et d'assurer sa traçabilité.
Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources.
Aucune imputation n'est réalisée à cette étape.
Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. Aucune imputation n'est réalisée à cette étape.
Le chargement des mesures est effectué par batches de 1 000 lignes.
Les données provenant du dataset CSV sont identifiées par :
Les données provenant du dataset CSV sont identifiées par `source = "csv"` et associées à leur `dataset_id`.
```text
source = "csv"
dataset_id = identifiant du dataset
```
### Résultats validés pour l'historique
### Résultats validés
Le chargement de référence a permis d'obtenir :
@@ -378,232 +290,14 @@ Le chargement de référence a permis d'obtenir :
- 122 647 mesures ;
- 0 doublon détecté dans le dataset source.
L'idempotence a également été vérifiée par une deuxième exécution du pipeline :
aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647.
L'idempotence a également été vérifiée par une deuxième exécution du pipeline : aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647.
La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline
est disponible dans `etl/README.md`.
La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`.
## Ingestion depuis l'API Mock
### Évolution prévue
Statut : `Fait`.
L'étape suivante consiste à orchestrer les traitements Data avec Apache Airflow.
La deuxième source du pipeline Data est l'API Mock EnerVision.
L'orchestration réutilisera la logique ETL existante afin de séparer la logique de traitement de la planification, du suivi des exécutions et de la gestion des erreurs.
Le traitement est implémenté dans :
```text
apps/backend/app/etl/mock_api_import.py
```
### Endpoints utilisés
Le pipeline récupère les informations des sites depuis :
```text
GET /api/v1/sites
```
puis les mesures historiques simulées depuis :
```text
GET /api/v1/readings
```
Pour `/api/v1/readings`, les informations suivantes sont envoyées :
```text
site_id
start_time
end_time
limit
```
Les paramètres de ligne de commande disponibles pour l'import sont :
```text
--start-time
--end-time
--limit
--dry-run
```
### Flux d'ingestion API Mock
```text
API Mock
|
+-----+------+
| |
v v
/sites /readings
| |
+-----+------+
|
v
mock_api_import.py
|
v
Transformation
+ qualité data
|
v
PostgreSQL / TimescaleDB
| |
v v
site reading
```
Les informations des sites sont insérées ou mises à jour dans `site`.
Les mesures sont enregistrées dans l'hypertable `reading` avec :
```text
source = "api_history"
dataset_id = NULL
```
Les données provenant de l'API Mock ne sont donc pas associées à un enregistrement de la table
`dataset`.
La réponse source reçue depuis l'API est conservée dans :
```text
raw_data
```
### Frontière de confiance avec l'API Mock
L'API Mock de l'école n'a aucune authentification et expose un endpoint mutatif à quiconque. Sa
réponse est donc traitée comme une entrée hostile, conformément à API10 dans
[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte,
c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction.
Quatre garde-fous, tous dans `mock_api_import.py` :
| Garde-fou | Mise en œuvre |
|---|---|
| Timeout | `APP_MOCK_API_TIMEOUT_SECONDS`, dix secondes par défaut |
| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site |
| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur |
| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus |
Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa
trace dans `null_reasons` sous la forme `out_of_physical_bounds:<colonne>`, et `data_quality`
descend à `degraded`. Une `data_quality` que `ck_reading_quality` refuserait devient `NULL`
plutôt que de faire échouer le lot entier. Dans tous les cas `raw_data` conserve la réponse
d'origine intacte : rien n'est perdu, seule son exploitation est bornée.
Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP
lui-même demanderait une lecture en flux, et reste à faire.
### Qualité des données de l'API Mock
Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion.
Les informations suivantes fournies par l'API sont conservées :
```text
data_quality
null_reasons
```
Cette conservation permet de distinguer une valeur manquante d'une valeur réelle égale à zéro
et de garder les informations liées aux éventuelles défaillances de capteurs.
Aucune imputation n'est réalisée pendant cette phase :
```text
imputed_values = NULL
imputation_method = NULL
```
### Validation de l'import API Mock
Un scénario de validation a été exécuté pour les 7 sites sur la période :
```text
15/06/2024 12:00 UTC
à
15/06/2024 13:00 UTC
```
avec :
```text
limit = 60
```
Résultat :
```text
7 sites
60 lectures par site
420 lectures récupérées
```
Les données ont été chargées dans PostgreSQL/TimescaleDB puis contrôlées directement en base.
Les contrôles ont confirmé :
- `source = "api_history"` ;
- `dataset_id = NULL` ;
- la conservation des valeurs `NULL` ;
- la conservation de `data_quality` ;
- la conservation de `null_reasons` ;
- la conservation de `raw_data`.
L'idempotence a été vérifiée en rejouant le même import.
Une mesure déjà présente n'est pas ajoutée une seconde fois.
Les tests automatisés couvrent également :
- la récupération des sites ;
- les paramètres envoyés à `/api/v1/readings` ;
- les réponses HTTP en erreur ;
- le format de la réponse ;
- la transformation des mesures ;
- les valeurs manquantes ;
- la qualité des données ;
- la conservation des données sources ;
- l'idempotence en base.
## Évolution prévue
La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow.
```text
CSV / JSON ----------------+
|
v
+------------------+
| Airflow |
+------------------+
|
+----------------+----------------+
| |
v v
historical_import.py mock_api_import.py
| |
+----------------+----------------+
|
v
PostgreSQL / TimescaleDB
```
Airflow servira à :
- planifier les traitements ;
- définir leur ordre d'exécution ;
- suivre leur état ;
- gérer et remonter les erreurs ;
- faciliter les exécutions récurrentes.
Airflow ne remplacera pas la logique ETL déjà implémentée.
Les scripts Python resteront responsables de l'extraction, de la validation, de la transformation
et du chargement des données.
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle
de Machine Learning.
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle de Machine Learning.
-208
View File
@@ -1,208 +0,0 @@
# Intégration et livraison continues
Ce document décrit la chaîne qui s'exécute entre un `git push` et un merge autorisé : ce qui est
vérifié, ce qui bloque, et ce qui ne l'est pas.
| Étage | Sert à | Statut |
|---|---|---|
| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` |
| Livraison continue | Porter un artefact vérifié jusqu'à la machine de déploiement | `Cible` |
Le **D** de CI/CD n'existe pas encore : aucun job de déploiement, aucune construction d'image
publiée, aucun environnement GitHub. L'issue #21 le porte. C'est la limite principale de cet
étage, et elle est nommée ici plutôt que découverte en soutenance.
## Vue d'ensemble
```mermaid
flowchart TB
push["push ou pull_request"]
subgraph back["Backend · .github/workflows/backend.yml"]
bv["verification<br/>ruff, mypy, pytest --cov-fail-under=85"]
bi["integration<br/>TimescaleDB réel + alembic upgrade head"]
bd["security-audit<br/>uv export | pip-audit"]
bs["sast<br/>bandit"]
end
subgraph front["Frontend · frontend.yml"]
fb["build<br/>npm ci, npm run build"]
ft["test<br/>couverture lcov"]
fd["security-audit<br/>npm audit --audit-level=high"]
end
subgraph mlw["ML · ml.yml"]
mv["verification<br/>ruff, mypy, pytest"]
ms["sast<br/>bandit"]
end
subgraph afw["Airflow · airflow.yml"]
av["verification<br/>ruff, intégrité des DAGs"]
ab["image<br/>construction de l'image"]
end
subgraph sq["SonarQube · sonarqube.yml"]
sb1["build-front / test-front"]
sb2["build-back / test-back"]
sb3["test-ml"]
sscan["sonarqube<br/>quality gate SonarCloud"]
end
push --> bv & bi & bd & bs
push --> fb --> ft
push --> fd
push --> mv & ms
push --> av & ab
push --> sb1 & sb2 --> sscan
sscan -.-> cd["deploy<br/>issue #21"]
```
## Déclenchement
Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** :
`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`,
`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun
incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline
déclenche le pipeline.
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux
modules : celles du ML pour `ml_train`/`ml_score`, celles du backend depuis que le DAG `alertes`
y exécute les commandes de détection ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
Une modification de l'un ou l'autre peut donc casser la construction de cette image, et le filtre
le voit.
**Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la
CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est
délibéré pendant le projet (retour au plus tôt, et la CI tournera sur `main` dès la remontée sans
rien changer), mais ce serait à borner sur un dépôt à forte fréquence de push.
`backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence
git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent.
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14, parce qu'Airflow 2.10
ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image
Docker et son propre environnement.
## Ce qui bloque un merge
| Gate | Où | Seuil | Effet d'un échec |
|---|---|---|---|
| Formatage `ruff format --check` | backend, ml | zéro écart | Bloque |
| Analyse statique `ruff check` | backend, ml | zéro constat | Bloque |
| Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque |
| Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque |
| Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque |
| Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque |
| Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque |
| **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque |
| Quality gate SonarCloud | tout le dépôt | gate par défaut, couverture du **code neuf** | Bloque |
| Build `npm run build` | frontend | compilation | Bloque |
| Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque |
| Construction de l'image Airflow | airflow | `docker build` de `etl/airflow/Dockerfile` | Bloque |
Deux seuils portent une décision qu'il faut savoir défendre :
- **`npm audit --audit-level=high`** et non `moderate` : une vulnérabilité modérée dans une
dépendance de développement ne doit pas immobiliser une livraison. Le corollaire est que les
`moderate` sont invisibles en CI, et qu'elles se regardent à la main.
- **Bandit bloque à partir de MEDIUM**, et une seconde passe sans seuil publie les constats LOW
sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le
revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert**
même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au
21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes
analysées.
- **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle,
une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et
le rejeu à l'identique promis plus bas n'existerait pas.
## Le job d'intégration, et pourquoi il ne suffisait pas d'un `postgres`
`backend.yml` monte un service `timescale/timescaledb-ha:pg17`, **la même image que
`docker-compose.yml`**, et non une image `postgres` nue. La première migration s'arrête
volontairement si l'extension TimescaleDB manque : un écart d'image entre la CI et le poste
rendrait ce job vert sur une base qui n'est pas la nôtre.
Sur le poste, c'est `db/init/110-test-database.sql` qui pose l'extension. Ce fichier n'est pas
monté dans le service GitHub Actions, d'où l'étape `CREATE EXTENSION IF NOT EXISTS timescaledb`
avant `alembic upgrade head`.
La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une
partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
## SonarCloud, et l'incident qui a immobilisé trois PR
Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`,
`build-back`, `test-back`, `test-ml`) dont les tests produisent chacun un rapport de couverture en
artefact, puis un dernier job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8`
avec le secret `SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine.
Le périmètre couvre `apps/frontend`, `apps/backend`, `ml/` et `etl/airflow` (les deux derniers
ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas
Sonar). `ml/` publie `ml/coverage.xml` (`pytest-cov`, même mécanisme que le backend, sans seuil
propre : la gate porte sur le code neuf). `etl/airflow` est exclu de la **couverture**
(`sonar.coverage.exclusions`) : ses tests ne font que charger les DAGs, ils ne mesurent rien.
Piège : tout nouveau dossier de tests doit être déclaré dans `sonar.tests`, faute de quoi il est
compté comme code de production non couvert (cf. l'incident ci-dessous).
**L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont
restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que
la couverture globale du backend dépassait 87 %. Le diagnostic était **hors du code de ces PR** :
`sonar.test.inclusions` ne reconnaissait que les fichiers `test_*.py`, si bien que
`tests/api/acces.py`, `tests/factories.py` et les `__init__.py` du dossier de tests étaient
comptés comme **code de production non couvert**. Le motif `tests` sans joker ne désignait par
ailleurs que la racine.
Deux commits ont corrigé la configuration (`9e6a5c0` classe tout `apps/backend/tests` comme test,
`af2b8cb` déclenche l'analyse quand `sonar-project.properties` change). La gate est verte sur
toutes les PR depuis. Ce qui compte pour la suite : **la cause a été traitée en configuration, pas
contournée** en désactivant la gate ou en excluant les fichiers gênants.
## Dependabot
`.github/dependabot.yml` déclare **six entrées hebdomadaires groupées, sur cinq écosystèmes** :
`npm` sur `/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, `docker` sur les
deux dossiers d'application, et `docker-compose` sur `/`. Les mises à jour arrivent en PR, donc
elles traversent les mêmes gates que n'importe quel changement : une montée de version qui casse
les tests ne se merge pas.
## Stratégie de branche et conventions
| Règle | Détail |
|---|---|
| Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` |
| Messages de commit | Conventional Commits |
| Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public |
| Revue | Toute PR passe par une revue écrite avant merge |
| ADR | Toute décision structurante porte son ADR dans la même PR |
| Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** |
## Secrets
Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions.
Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le
workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le
run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement : le job de
déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par
l'issue #22.
## Ce qui manque, et pourquoi
| Manque | Issue | Conséquence assumée |
|---|---|---|
| Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine |
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
| Tests de charge | #47 | Aucun garde-fou de performance |
| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés |
## Reproduire la CI en local
`make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job
`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration
demandent une base : `make db-up` puis `uv run pytest -m integration`.
Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium
--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis
`ml`.
+3 -8
View File
@@ -15,15 +15,10 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
| [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur |
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
| [50-cicd.md](50-cicd.md) | Workflows, gates bloquantes, SonarCloud, Dependabot, ce qui manque |
La CI/CD a désormais son document : cinq workflows et seize jobs, c'est assez de matière pour
qu'une section de plus dans une autre vue devienne illisible. L'observabilité, elle, n'en a
toujours pas : `monitoring/` ne contient que des `.gitkeep`. Elle en sortira le jour où elle aura
de la matière. Un fichier vide de plus n'aide personne.
L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : trois DAGs, leur image et
leurs contraintes sont décrits dans [10-infra.md](10-infra.md).
L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents
ci-dessus, tant que `monitoring/` et `etl/airflow/` ne contiennent que des `.gitkeep`. Elles en
sortiront le jour où elles auront de la matière. Un fichier vide de plus n'aide personne.
La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans
[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la
+3 -8
View File
@@ -41,27 +41,22 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
| En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 |
| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design |
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components |
| Terminaison TLS au frontal, redirection 80 vers 443, HSTS et CSP posés par le proxy, limitation de débit au frontal | `infra/proxy/conf.d/enervision.conf`, ADR 0007 | API8 Security Misconfiguration, A05 |
Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des
règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01.
Note sur API8 : le transport est couvert, le certificat ne l'est qu'à moitié. Tant qu'aucun nom de
domaine public ne résout vers la machine, le défi HTTP-01 de Let's Encrypt ne peut pas aboutir et
le certificat servi reste auto-signé. Le chemin ACME est livré et documenté, pas exercé.
## Non couvert, et pourquoi
| Item | État | Raison |
|---|---|---|
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. Plus coûteuse depuis Airflow (#115) : ce service publie le port 8080, détient les identifiants Postgres complets (`ML_DATABASE_URL`, mêmes que le backend) et permet de déclencher l'exécution de code depuis son interface. Un compte Airflow compromis atteint donc toute la base, pas seulement `reading`/`site`. Aggravée par #116 : le conteneur reçoit aussi `DATABASE_URL` et exécute le code du backend en sous-processus (ADR 0008). Atténuations en place : le compte admin Airflow est distinct des `app_user` et son mot de passe passe par l'environnement, jamais par `argv` ; et l'`APP_SECRET_KEY` donnée à Airflow est distincte de celle de l'API, pour qu'une compromission ne livre pas la clé de signature des JWT. |
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. |
| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. |
## Ce qu'il faut répondre, et ne pas répondre
+27 -348
View File
@@ -2,10 +2,9 @@
## Objectif
Le pipeline ETL EnerVision permet d'intégrer les données énergétiques dans PostgreSQL/TimescaleDB à partir de deux sources :
Le pipeline ETL EnerVision permet d'intégrer les données énergétiques historiques dans PostgreSQL/TimescaleDB.
- le dataset historique CSV/JSON fourni dans le cadre du projet ;
- l'API Mock EnerVision.
Cette première étape du pipeline Data permet de charger le dataset fourni dans le cadre du projet, contenant les mesures énergétiques de 7 sites sur la période du 1er janvier 2023 au 31 décembre 2024.
Le pipeline assure :
@@ -13,15 +12,12 @@ Le pipeline assure :
- la validation de leur structure et de leur cohérence ;
- la normalisation des données nécessaires au stockage ;
- le suivi de la qualité des données ;
- la traçabilité des données importées ;
- la traçabilité du dataset importé ;
- le chargement des données dans PostgreSQL/TimescaleDB ;
- la conservation des valeurs manquantes et des informations de qualité ;
- l'idempotence du chargement afin d'éviter la création de doublons.
## Données sources
### Dataset historique
Le dataset est fourni par le formateur dans le cadre du projet EnerVision.
Il contient les deux fichiers suivants :
@@ -33,7 +29,7 @@ dataset_metadata.json
Ces fichiers sont nécessaires une seule fois pour initialiser les données historiques de l'environnement.
Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement les fichiers fournis par le formateur et les place dans :
Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement une fois les fichiers fournis par le formateur et les place dans :
```text
data/raw/
@@ -51,26 +47,14 @@ data/
Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/` dans Git. Les fichiers CSV et JSON sont ignorés par Git.
### API Mock
La deuxième source est l'API Mock EnerVision.
Elle permet de récupérer :
- les informations des sites avec `GET /api/v1/sites` ;
- les mesures simulées avec `GET /api/v1/readings`.
L'API Mock est utilisée pour compléter les données historiques avec des mesures simulées récupérées sur une période donnée.
## Technologies utilisées
| Technologie | Utilisation |
|---|---|
| Python | Développement du pipeline ETL |
| Pandas | Lecture, validation et transformation du dataset historique |
| JSON | Lecture des métadonnées et conservation des données sources |
| HTTPX | Appels HTTP asynchrones vers l'API Mock |
| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset historique |
| Pandas | Lecture, validation et transformation des données |
| JSON | Lecture des métadonnées du dataset |
| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset |
| SQLAlchemy Async | Connexion et chargement asynchrone en base |
| PostgreSQL | Stockage relationnel |
| TimescaleDB | Stockage des séries temporelles énergétiques |
@@ -78,14 +62,11 @@ L'API Mock est utilisée pour compléter les données historiques avec des mesur
| Alembic | Gestion des migrations du schéma |
| uv | Gestion et exécution de l'environnement Python |
| Ruff | Contrôle de la qualité du code |
| mypy | Vérification du typage |
| Pytest | Tests automatisés |
## Import du dataset historique
## Fonctionnement du pipeline
### Fonctionnement du pipeline historique
Le script d'import se trouve dans :
Le script principal d'import se trouve dans :
```text
apps/backend/app/etl/historical_import.py
@@ -115,14 +96,14 @@ CSV + métadonnées JSON
PostgreSQL / TimescaleDB
```
#### 1. Extraction
### 1. Extraction
Le pipeline charge :
- `all_sites_combined.csv` avec Pandas ;
- `dataset_metadata.json` avec le module JSON de Python.
#### 2. Validation
### 2. Validation
Avant toute écriture en base, le pipeline contrôle notamment :
@@ -136,7 +117,7 @@ Avant toute écriture en base, le pipeline contrôle notamment :
Une incohérence détectée pendant cette étape interrompt l'import avant le chargement.
#### 3. Dry-run
### 3. Dry-run
Un mode `--dry-run` permet d'exécuter les contrôles sans écrire de données dans PostgreSQL.
@@ -149,7 +130,7 @@ Il permet notamment de vérifier :
- les valeurs NULL ;
- l'empreinte SHA-256.
#### 4. Traçabilité
### 4. Traçabilité
Une empreinte SHA-256 est calculée à partir du fichier CSV afin d'identifier le dataset utilisé.
@@ -161,7 +142,7 @@ Empreinte SHA-256 du dataset validé :
Cette empreinte participe à la traçabilité du dataset chargé.
#### 5. Transformation
### 5. Transformation
Les timestamps sont normalisés avec la timezone :
@@ -180,7 +161,7 @@ imputed_values = NULL
imputation_method = NULL
```
#### 6. Chargement
### 6. Chargement
Le chargement est réalisé avec SQLAlchemy Async dans PostgreSQL/TimescaleDB.
@@ -207,7 +188,7 @@ dataset_id = identifiant du dataset
Cette représentation respecte les contraintes définies dans le schéma de la base.
### Dataset validé
## Dataset validé
Le dataset traité contient :
@@ -226,7 +207,7 @@ Valeurs manquantes identifiées :
| `humidity_percent` | 3 423 |
| `solar_irradiance_wm2` | 3 964 |
### Exécution historique en dry-run
## Exécution en dry-run
Depuis le dossier :
@@ -246,7 +227,7 @@ uv run python -m app.etl.historical_import `
Aucune donnée n'est écrite dans la base pendant cette exécution.
### Chargement historique réel
## Chargement réel
Depuis `apps/backend/` :
@@ -268,7 +249,7 @@ Chargement : 2000/122647
Chargement : 122647/122647
```
### Résultats obtenus pour le dataset historique
## Résultats obtenus
Après le chargement initial, les contrôles en base ont confirmé :
@@ -285,7 +266,7 @@ Le premier import a créé :
nouvelles lectures : 122647
```
### Idempotence du dataset historique
## Idempotence
Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son idempotence.
@@ -299,7 +280,7 @@ nouvelles lectures : 0
Une nouvelle exécution du même import ne crée donc pas de mesures supplémentaires pour le dataset testé.
### Vérifications SQL du dataset historique
## Vérifications SQL
Depuis la racine du projet, vérifier le nombre d'enregistrements avec :
@@ -321,251 +302,21 @@ Vérifier la source des mesures avec :
docker compose exec db psql -U enervision -d enervision -c "SELECT source, COUNT(*) FROM reading GROUP BY source ORDER BY source;"
```
Résultat attendu pour le dataset historique :
Résultat attendu :
```text
csv | 122647
```
## Import depuis l'API Mock
### Fonctionnement
Le script d'import de l'API Mock se trouve dans :
```text
apps/backend/app/etl/mock_api_import.py
```
Le flux est le suivant :
```text
API Mock
|
+-----+------+
| |
v v
/sites /readings
| |
+-----+------+
|
v
mock_api_import.py
|
v
Transformation
+ qualité data
|
v
PostgreSQL / TimescaleDB
| |
v v
site reading
```
Le pipeline commence par récupérer les sites avec :
```text
GET /api/v1/sites
```
Il récupère ensuite les mesures de chaque site avec :
```text
GET /api/v1/readings
```
Les paramètres envoyés à `/api/v1/readings` sont :
```text
site_id
start_time
end_time
limit
```
Le paramètre `limit` doit être compris entre 1 et 1000.
### Configuration de l'API Mock
La connexion à l'API Mock est configurée avec les variables d'environnement suivantes :
```text
APP_MOCK_API_BASE_URL
APP_MOCK_API_USERNAME
APP_MOCK_API_PASSWORD
APP_MOCK_API_TIMEOUT_SECONDS
```
Les identifiants réels ne sont pas versionnés dans Git.
Les fichiers `.env.example` indiquent uniquement les variables nécessaires à l'exécution.
### Transformation des mesures API
Les mesures provenant de l'API Mock sont enregistrées dans `reading` avec :
```text
source = "api_history"
dataset_id = NULL
```
Les mesures provenant de l'API ne sont donc pas rattachées à un dataset historique.
Le timestamp reçu depuis l'API est converti en `datetime` avec timezone avant le chargement.
La réponse source est conservée dans :
```text
raw_data
```
afin de préserver la donnée reçue et faciliter la traçabilité.
### Qualité des données API
Les valeurs `NULL` fournies par l'API sont conservées telles quelles.
Une valeur manquante n'est pas transformée en zéro et la mesure n'est pas supprimée.
Le pipeline conserve également :
```text
data_quality
null_reasons
```
Les niveaux de qualité possibles sont :
```text
good
partial
degraded
critical
```
Ce sont les quatre seules valeurs que la contrainte `ck_reading_quality` accepte. Toute autre
valeur renvoyée par l'API est remplacée par `NULL` plutôt que de faire échouer le lot entier.
Aucune imputation n'est réalisée pendant l'ingestion :
```text
imputed_values = NULL
imputation_method = NULL
```
Cette stratégie permet de distinguer une véritable valeur nulle ou manquante d'une consommation égale à zéro et de conserver les informations liées aux défaillances de capteurs.
### Bornes physiques et frontière de confiance
La réponse de l'API Mock est traitée comme une entrée hostile : l'API n'a pas
d'authentification et expose un endpoint mutatif à quiconque. Voir API10 dans
`docs/architecture/owasp-traceabilite.md`.
Les plages acceptées sont déclarées dans `PHYSICAL_BOUNDS` :
| Grandeur | Plage acceptée |
|---|---|
| `consumption_kw` | 0 à 100 000 |
| `consumption_kwh` | 0 à 100 000 |
| `voltage_v` | 0 à 1 000 |
| `current_a` | 0 à 10 000 |
| `power_factor` | 0 à 1 |
| `temperature_celsius` | -90 à 60 |
| `humidity_percent` | 0 à 100 |
| `capacity_kw` | 0 à 100 000 |
Une valeur hors plage, d'un type inattendu, `NaN` ou infinie devient `NULL` :
```text
null_reasons += "out_of_physical_bounds:<colonne>"
data_quality = "degraded"
```
L'import ne s'interrompt pas pour autant : le mock émet des anomalies par construction, et
`raw_data` conserve la réponse d'origine.
La taille des réponses est plafonnée : au plus `MAX_SITES` sites, et au plus `--limit` mesures
par site. Au-delà, l'import échoue au lieu de charger.
Enfin, seuls les champs attendus sont recopiés vers la base. Une clé supplémentaire renvoyée par
l'API n'atteint jamais une colonne.
### Dry-run de l'API Mock
Le mode `--dry-run` permet de tester la connexion, la récupération des sites et la récupération des mesures sans écrire dans PostgreSQL.
Depuis `apps/backend/` :
```powershell
uv run python -m app.etl.mock_api_import `
--start-time "2024-06-15T12:00:00" `
--end-time "2024-06-15T13:00:00" `
--limit 60 `
--dry-run
```
### Chargement réel depuis l'API Mock
Depuis `apps/backend/` :
```powershell
uv run python -m app.etl.mock_api_import `
--start-time "2024-06-15T12:00:00" `
--end-time "2024-06-15T13:00:00" `
--limit 60
```
### Résultat validé pour l'API Mock
Le scénario de validation utilisé couvre la période :
```text
15/06/2024 12:00 UTC
à
15/06/2024 13:00 UTC
```
avec une limite de 60 lectures par site.
Résultat obtenu :
```text
sites récupérés : 7
lectures par site : 60
lectures récupérées : 420
source : api_history
dataset_id : NULL
```
Les contrôles effectués directement dans PostgreSQL/TimescaleDB ont confirmé :
- l'enregistrement des mesures dans `reading` ;
- la présence des 7 sites ;
- `source = "api_history"` ;
- `dataset_id = NULL` ;
- la conservation des valeurs `NULL` ;
- la conservation de `data_quality` ;
- la conservation de `null_reasons` ;
- la conservation de la donnée source dans `raw_data`.
### Idempotence de l'import API Mock
Le même import a été exécuté plusieurs fois afin de vérifier qu'une mesure déjà présente n'est pas créée une seconde fois.
L'idempotence repose sur la contrainte d'unicité de la table `reading` et sur la gestion des conflits lors de l'insertion.
Un test d'intégration automatisé vérifie également ce comportement.
## Tests et qualité
Les tests automatisés des pipelines ETL sont situés dans :
Les tests automatisés du pipeline sont situés dans :
```text
apps/backend/tests/etl/
```
Les tests de l'import historique couvrent notamment :
Ils couvrent notamment :
- la validation du dataset ;
- les colonnes obligatoires ;
@@ -577,94 +328,22 @@ Les tests de l'import historique couvrent notamment :
- la construction des mesures destinées à la BDD ;
- le respect des contraintes du modèle de données.
Les tests de l'import API Mock couvrent notamment :
- la récupération des sites ;
- l'appel à `/api/v1/readings` ;
- les paramètres `site_id`, `start_time`, `end_time` et `limit` ;
- la gestion des erreurs HTTP ;
- la validation du format de la réponse ;
- la transformation des mesures ;
- la conservation des valeurs `NULL` ;
- la conservation de `data_quality` et `null_reasons` ;
- `source = "api_history"` ;
- `dataset_id = NULL` ;
- la conservation de `raw_data` ;
- l'idempotence du chargement.
Exécuter les tests ETL :
```powershell
uv run pytest tests\etl -v
```
Exécuter les tests unitaires de l'import API Mock :
```powershell
uv run pytest tests\etl\test_mock_api_import.py -v
```
Exécuter le test d'intégration de l'import API Mock :
```powershell
uv run pytest tests\etl\test_mock_api_import.py -m integration -v
```
Contrôler la qualité du code :
```powershell
uv run ruff check app\etl tests\etl
```
Contrôler le typage :
```powershell
uv run mypy app
```
Exécuter la suite complète avec le seuil de couverture :
```powershell
uv run pytest --cov-fail-under=85
```
Lors de la validation de l'import API Mock :
```text
8 tests unitaires passés
1 test d'intégration passé
```
La suite backend complète a également été validée avec une couverture supérieure au seuil de 85 %.
## Suite du pipeline Data
Deux sources de données sont maintenant prises en charge :
L'import historique constitue la première brique du pipeline Data EnerVision.
```text
Dataset CSV/JSON
|
v
historical_import.py
|
+-----------------+
|
v
PostgreSQL / TimescaleDB
^
|
+-----------------+
|
mock_api_import.py
^
|
API Mock
```
La prochaine étape consiste à orchestrer les traitements ETL avec Apache Airflow, puis à préparer les données nécessaires à l'entraînement du modèle de Machine Learning.
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline ML (`ml_train`/`ml_score`, issue #115) ainsi que la détection d'alertes et la génération des recommandations (`alertes`, issue #116). Il n'orchestre pas encore ces deux imports : `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) restent à faire.
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
Airflow sera utilisé comme orchestrateur des traitements existants et ne remplacera pas la logique métier déjà implémentée dans le pipeline ETL.
-1
View File
@@ -1 +0,0 @@
3.12
-51
View File
@@ -1,51 +0,0 @@
# Image Airflow EnerVision : ajoute ml/ et apps/backend/ dans leurs propres environnements Python
# 3.14, distincts du Python 3.12 qui fait tourner Airflow lui-meme (apache-airflow 2.10 ne supporte
# pas 3.14), pour que les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`,
# `app.detection.internal_alerts` et `app.cli` en sous-processus. Airflow ne devient jamais un
# consommateur direct de LightGBM, de MLflow ou du SQLAlchemy du backend. Cf. ADR 0008.
FROM apache/airflow:2.10.4-python3.12
# LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans
# toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file`
# au premier `import lightgbm`, seulement au moment ou une tache tourne reellement.
USER root
RUN apt-get update \
&& apt-get install --no-install-recommends -y libgomp1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Pre-cree, appartenant a `airflow` : docker-compose y monte un volume nomme partage entre
# `ml_train` et `ml_score` (le modele ecrit par l'un, lu par l'autre). Un volume nomme herite des
# permissions du repertoire qu'il recouvre a son premier montage ; sans ce chown prealable, il
# serait cree root:root et illisible par le conteneur, qui tourne en `airflow` (uid 50000).
# `/opt/backend` ne porte aucun volume, mais `WORKDIR` le creerait root meme sous `USER airflow`.
RUN mkdir -p /opt/ml/state /opt/backend && chown -R airflow:root /opt/ml /opt/backend
USER airflow
# L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de
# `ml/uv.lock`. On le remplace par la version deja pinnee ailleurs dans le depot
# (apps/backend/Dockerfile).
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv
# Piege : pas de `UV_PROJECT_ENVIRONMENT` global. Il vaudrait pour les deux projets, et `uv run`
# dans l'un resoudrait le venv de l'autre. Par defaut, uv prend `<projet>/.venv`, donc le bon.
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /opt/ml
COPY --chown=airflow:root ml/pyproject.toml ml/uv.lock ./
RUN uv sync --locked --no-install-project --no-dev
COPY --chown=airflow:root ml/enervision_ml ./enervision_ml
RUN uv sync --locked --no-dev
WORKDIR /opt/backend
# `packages = ["app"]` : le reste de apps/backend (alembic, tests) n'a rien a faire dans l'image.
COPY --chown=airflow:root apps/backend/pyproject.toml apps/backend/uv.lock ./
RUN uv sync --locked --no-install-project --no-dev
COPY --chown=airflow:root apps/backend/app ./app
RUN uv sync --locked --no-dev
WORKDIR /opt/airflow
-65
View File
@@ -1,65 +0,0 @@
"""DAG de détection des alertes et de génération des recommandations (issue #116).
Ordonnance ce que `docs/architecture/20-backend.md` et l'ADR 0006 décrivent encore comme lancé à
la main. Toute la logique reste dans `apps/backend`, ce DAG ne fait que l'appeler, sur le patron
de `ml_score` (cf. `docs/architecture/10-infra.md`, section Airflow, et l'ADR 0008 pour
l'environnement `/opt/backend` que l'image embarque désormais).
Planifié à la quinzième minute plutôt qu'à l'heure pile : la règle `anomaly` compare une lecture
à la `prediction` du même instant, que `ml_score` (`@hourly`) vient d'écrire. Aucune dépendance
déclarée entre les deux DAGs pour autant, quatre règles sur cinq ne touchent pas au modèle et un
modèle jamais entraîné ne doit pas priver le parc de ses alertes.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from airflow.models.dag import DAG
from airflow.operators.bash import BashOperator
# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et
# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement.
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
# Les deux tâches sont idempotentes en base (`ON CONFLICT DO NOTHING` sur
# `uq_alert_source_reference` et `uq_recommendation_alert_rule`) : reprendre ne duplique rien.
TENTATIVES = 2
DELAI_ENTRE_TENTATIVES = timedelta(minutes=2)
# `execution_timeout` vaut par tentative : c'est le pire cas des deux tâches enchaînées, reprises
# et délais compris, qui doit tenir sous le pas horaire. Les tests d'intégrité en font le calcul.
PLAFOND_PAR_TACHE = timedelta(minutes=5)
with DAG(
dag_id="alertes",
description=(
"Détecte les alertes internes puis génère les recommandations "
"(app.detection.internal_alerts, app.cli)."
),
schedule="15 * * * *",
start_date=datetime(2026, 1, 1),
catchup=False,
# Deux exécutions simultanées analyseraient la même fenêtre de 48h, et la génération relit
# l'intégralité de la table `alert` à chaque passage.
max_active_runs=1,
tags=["alertes"],
) as dag:
detection = BashOperator(
task_id="detection",
bash_command=f"{COMMANDE_BACKEND} app.detection.internal_alerts",
retries=TENTATIVES,
retry_delay=DELAI_ENTRE_TENTATIVES,
execution_timeout=PLAFOND_PAR_TACHE,
)
recommandations = BashOperator(
task_id="recommandations",
bash_command=f"{COMMANDE_BACKEND} app.cli generate-recommendations",
retries=TENTATIVES,
retry_delay=DELAI_ENTRE_TENTATIVES,
execution_timeout=PLAFOND_PAR_TACHE,
)
# `recommendation.alert_id` est une clé étrangère `NOT NULL` : la génération n'a rien à lire
# tant que la détection n'a pas écrit.
detection >> recommandations
-41
View File
@@ -1,41 +0,0 @@
"""DAG de scoring horaire du modele LightGBM (issue #115).
Planifie toutes les heures, au rythme documente par `enervision_ml.score` (score le prochain pas
horaire par site). Reutilise le modele ecrit par `ml_train` (DAG separe, declenche a la main) :
ce DAG ne reentraine jamais rien. Si aucun modele n'a encore ete entraine, la tache echoue
(`FileNotFoundError`) plutot que de rester silencieuse.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from airflow.models.dag import DAG
from airflow.operators.bash import BashOperator
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
with DAG(
dag_id="ml_score",
description="Score le prochain pas horaire par site (enervision_ml.score).",
schedule="@hourly",
start_date=datetime(2026, 1, 1),
catchup=False,
# Deux scorings qui se chevauchent inseraient en meme temps dans `prediction` (pas de contrainte
# d'unicite sur `(site_id, target_at)`, chaque run garde sa ligne).
max_active_runs=1,
tags=["ml"],
) as dag:
# `--no-sync`, `env -u VIRTUAL_ENV` : cf. `ml_train.py`, meme raisonnement.
BashOperator(
task_id="score",
bash_command=(
"cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.score "
f"--model {MODEL_PATH}"
),
# Un incident transitoire sur Postgres ne doit pas faire perdre le creneau horaire.
retries=2,
retry_delay=timedelta(minutes=2),
# Bien en dessous du pas horaire : un scoring pendu ne doit pas empieter sur le suivant.
execution_timeout=timedelta(minutes=30),
)
-43
View File
@@ -1,43 +0,0 @@
"""DAG d'entrainement du modele LightGBM (issue #115).
Pas de planification : reentrainer est couteux et sa cadence n'est pas une decision prise, en
particulier tant que `train.py` ecrase le modele sans comparer ses metriques a l'ancien (cf.
`docs/architecture/10-infra.md`, section Airflow). Declenchement manuel depuis l'UI ou la CLI
Airflow en attendant. `ml_score` (DAG separe, planifie toutes les heures) reutilise le modele que
ce DAG ecrit, il ne reentraine jamais rien lui-meme.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from airflow.models.dag import DAG
from airflow.operators.bash import BashOperator
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
MLFLOW_TRACKING_URI = "sqlite:////opt/ml/state/mlflow.db"
with DAG(
dag_id="ml_train",
description="Entraine le modele LightGBM de prevision de consommation (enervision_ml.train).",
schedule=None,
start_date=datetime(2026, 1, 1),
catchup=False,
# Deux entrainements simultanes ecriraient le meme fichier modele.
max_active_runs=1,
tags=["ml"],
) as dag:
# `--no-sync` : l'environnement `/opt/ml/.venv` est fige a la construction de l'image, `uv run`
# ne le resynchronise pas (sinon `enervision-ml` est reconstruit a chaque tache).
# `env -u VIRTUAL_ENV` : l'image de base positionne celui d'Airflow, que `uv` signale a chaque
# execution sans qu'il change quoi que ce soit.
BashOperator(
task_id="train",
bash_command=(
"cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train "
f"--model-output {MODEL_PATH} --mlflow-tracking-uri {MLFLOW_TRACKING_URI}"
),
# Un entrainement complet dure quelques minutes ; une connexion pendue ne doit pas
# immobiliser un slot du scheduler indefiniment.
execution_timeout=timedelta(hours=1),
)

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