Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe100653d0 | ||
|
|
cb4df846cb | ||
|
|
ae4082c584 | ||
|
|
f18d4f9ef9 | ||
|
|
308769b325 | ||
|
|
3c378c177f | ||
|
|
2adfdf0eb0 | ||
|
|
44f3416ffe | ||
|
|
342128ccff | ||
|
|
62d81e901d | ||
|
|
31886944ab | ||
|
|
63cbeafe3b | ||
|
|
f8d08c8686 | ||
|
|
be44b97d7a | ||
|
|
306c5a52e5 | ||
|
|
801379f956 | ||
|
|
2686880185 | ||
|
|
ae58a896d9 | ||
|
|
5a29faaa16 | ||
|
|
bc75528616 | ||
|
|
d7457e9fd9 | ||
|
|
3cd9a6b272 | ||
|
|
26f834485c | ||
|
|
777cd0ac64 | ||
|
|
c528ed239b | ||
|
|
a88e51c92a | ||
|
|
f3ea2785b3 | ||
|
|
901ceffd72 | ||
|
|
5545c166fd | ||
|
|
f0ad8e9990 | ||
|
|
3c01ab3ecc | ||
|
|
6f6f451eb4 | ||
|
|
cc3e38efa3 | ||
|
|
0a2ed5ad8f | ||
|
|
459ddf1792 | ||
|
|
de697b080d | ||
|
|
f238940867 | ||
|
|
b941880c22 | ||
|
|
9d2384a639 | ||
|
|
0c487fa7be | ||
|
|
b3efb98208 | ||
|
|
2d7b4bd74d | ||
|
|
19c38fe571 | ||
|
|
9a1af94d88 | ||
|
|
f9c2a4610c | ||
|
|
b5fa7b0010 | ||
|
|
aeb07e14db | ||
|
|
7f710c9084 | ||
|
|
c3fd9327ea |
@@ -17,9 +17,39 @@ APP_LOG_LEVEL=INFO
|
|||||||
APP_SECRET_KEY=change_me
|
APP_SECRET_KEY=change_me
|
||||||
APP_CORS_ORIGINS=http://localhost:4200
|
APP_CORS_ORIGINS=http://localhost:4200
|
||||||
BACKEND_PORT=8000
|
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
|
# API Mock EnerVision
|
||||||
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
|
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
|
||||||
APP_MOCK_API_USERNAME=change_me
|
APP_MOCK_API_USERNAME=change_me
|
||||||
APP_MOCK_API_PASSWORD=change_me
|
APP_MOCK_API_PASSWORD=change_me
|
||||||
APP_MOCK_API_TIMEOUT_SECONDS=10
|
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=
|
||||||
|
|||||||
@@ -38,3 +38,9 @@ updates:
|
|||||||
directory: "/apps/frontend"
|
directory: "/apps/frontend"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
|
||||||
|
# Images du reverse proxy et du compagnon ACME, épinglées dans les fichiers Compose
|
||||||
|
- package-ecosystem: "docker-compose"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
name: Airflow
|
||||||
|
|
||||||
|
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
|
||||||
|
# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de
|
||||||
|
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
|
||||||
|
#
|
||||||
|
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
|
||||||
|
# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans
|
||||||
|
# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "etl/airflow/**"
|
||||||
|
- "ml/pyproject.toml"
|
||||||
|
- "ml/uv.lock"
|
||||||
|
- "ml/enervision_ml/**"
|
||||||
|
- "apps/backend/pyproject.toml"
|
||||||
|
- "apps/backend/uv.lock"
|
||||||
|
- "apps/backend/app/**"
|
||||||
|
- ".github/workflows/airflow.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "etl/airflow/**"
|
||||||
|
- "ml/pyproject.toml"
|
||||||
|
- "ml/uv.lock"
|
||||||
|
- "ml/enervision_ml/**"
|
||||||
|
- "apps/backend/pyproject.toml"
|
||||||
|
- "apps/backend/uv.lock"
|
||||||
|
- "apps/backend/app/**"
|
||||||
|
- ".github/workflows/airflow.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: airflow-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verification:
|
||||||
|
name: Lint et intégrité des DAGs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: etl/airflow
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: etl/airflow/uv.lock
|
||||||
|
|
||||||
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
|
run: uv python install
|
||||||
|
|
||||||
|
- name: Synchronise les dépendances sans dévier du verrou
|
||||||
|
run: uv sync --all-groups --frozen
|
||||||
|
|
||||||
|
- name: Vérifie le formatage
|
||||||
|
run: uv run ruff format --check .
|
||||||
|
|
||||||
|
- name: Analyse statique
|
||||||
|
run: uv run ruff check --output-format=github .
|
||||||
|
|
||||||
|
# Aucun test ne lance de tâche ni de scheduler : DagBag charge les fichiers de dags/ et
|
||||||
|
# vérifie import, planification, plafonds d'exécution et commande de chaque tâche.
|
||||||
|
- name: Tests d'intégrité des DAGs
|
||||||
|
run: uv run pytest
|
||||||
|
|
||||||
|
image:
|
||||||
|
name: Construction de l'image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
|
||||||
|
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
|
||||||
|
|
||||||
|
# Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent
|
||||||
|
# (`OSError: libgomp.so.1` au premier import) ou environnement ml/ non figé.
|
||||||
|
- name: Vérifie que le pipeline ML s'importe sans réseau
|
||||||
|
run: >
|
||||||
|
docker run --rm --network none enervision-airflow:ci
|
||||||
|
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
|
||||||
|
|
||||||
|
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
|
||||||
|
# l'import des modules prouve que l'environnement /opt/backend est complet.
|
||||||
|
# Les deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes.
|
||||||
|
- name: Vérifie que les trois commandes backend s'importent sans réseau
|
||||||
|
run: >
|
||||||
|
docker run --rm --network none enervision-airflow:ci
|
||||||
|
bash -c "cd /opt/backend
|
||||||
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
|
||||||
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
|
||||||
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help"
|
||||||
@@ -140,3 +140,29 @@ jobs:
|
|||||||
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
|
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
|
||||||
shell: bash
|
shell: bash
|
||||||
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
|
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
|
||||||
|
|
||||||
|
sast:
|
||||||
|
name: Analyse statique de sécurité
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||||
|
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
|
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
|
||||||
|
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
|
||||||
|
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||||
|
run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium
|
||||||
|
|
||||||
|
# Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace.
|
||||||
|
- name: Rapport complet, tous niveaux
|
||||||
|
continue-on-error: true
|
||||||
|
run: uvx bandit==1.9.4 --recursive app
|
||||||
|
|||||||
@@ -57,3 +57,26 @@ jobs:
|
|||||||
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
|
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: uv run pytest
|
run: uv run pytest
|
||||||
|
|
||||||
|
sast:
|
||||||
|
name: Analyse statique de sécurité
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||||
|
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
|
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||||
|
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
|
||||||
|
|
||||||
|
- name: Rapport complet, tous niveaux
|
||||||
|
continue-on-error: true
|
||||||
|
run: uvx bandit==1.9.4 --recursive enervision_ml
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "apps/frontend/**"
|
- "apps/frontend/**"
|
||||||
- "apps/backend/**"
|
- "apps/backend/**"
|
||||||
|
- "ml/**"
|
||||||
|
- "etl/airflow/**"
|
||||||
- ".github/workflows/sonarqube.yml"
|
- ".github/workflows/sonarqube.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "apps/frontend/**"
|
- "apps/frontend/**"
|
||||||
- "apps/backend/**"
|
- "apps/backend/**"
|
||||||
|
- "ml/**"
|
||||||
|
- "etl/airflow/**"
|
||||||
- ".github/workflows/sonarqube.yml"
|
- ".github/workflows/sonarqube.yml"
|
||||||
|
|
||||||
|
|
||||||
@@ -108,8 +112,36 @@ jobs:
|
|||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
path: apps/backend/coverage.xml
|
path: apps/backend/coverage.xml
|
||||||
|
|
||||||
|
test-ml:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: ml/uv.lock
|
||||||
|
|
||||||
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
|
run: uv python install
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Synchronise les dépendances sans dévier du verrou
|
||||||
|
run: uv sync --all-groups --frozen
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Lancement des tests et génération du rapport de couverture (ML)
|
||||||
|
run: uv run pytest --cov-report=xml
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ml-coverage
|
||||||
|
path: ml/coverage.xml
|
||||||
|
|
||||||
sonarqube:
|
sonarqube:
|
||||||
needs: [build-front, build-back, test-front, test-back]
|
needs: [build-front, build-back, test-front, test-back, test-ml]
|
||||||
name: SonarQube
|
name: SonarQube
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -126,6 +158,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
path: apps/backend
|
path: apps/backend
|
||||||
|
- name: Téléchargement du rapport de couverture (ML)
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ml-coverage
|
||||||
|
path: ml
|
||||||
- name: SonarQube Scan
|
- name: SonarQube Scan
|
||||||
uses: SonarSource/sonarqube-scan-action@v8
|
uses: SonarSource/sonarqube-scan-action@v8
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ ml/mlruns/
|
|||||||
ml/mlartifacts/
|
ml/mlartifacts/
|
||||||
ml/mlflow.db
|
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
|
# IDE et OS
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
BACKEND := apps/backend
|
BACKEND := apps/backend
|
||||||
FRONTEND := apps/frontend
|
FRONTEND := apps/frontend
|
||||||
ML := ml
|
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
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
|
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
||||||
|
dev dev-backend dev-frontend \
|
||||||
lint format typecheck test test-cov test-integration check \
|
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 \
|
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
|
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
|
||||||
|
|
||||||
help: ## Liste les cibles disponibles
|
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}'
|
@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 ## Installe les dépendances backend, frontend et ML
|
install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow
|
||||||
|
|
||||||
install-backend: ## Installe les dépendances du backend
|
install-backend: ## Installe les dépendances du backend
|
||||||
cd $(BACKEND) && uv sync --all-groups
|
cd $(BACKEND) && uv sync --all-groups
|
||||||
@@ -22,6 +36,9 @@ install-frontend: ## Installe les dépendances du frontend
|
|||||||
install-ml: ## Installe les dépendances du pipeline ML
|
install-ml: ## Installe les dépendances du pipeline ML
|
||||||
cd $(ML) && uv sync --all-groups
|
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
|
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
|
||||||
@trap 'kill 0' EXIT INT TERM; \
|
@trap 'kill 0' EXIT INT TERM; \
|
||||||
$(MAKE) --no-print-directory dev-backend & \
|
$(MAKE) --no-print-directory dev-backend & \
|
||||||
@@ -77,9 +94,62 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT
|
|||||||
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
|
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),)
|
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: ## Construit l'image du backend
|
||||||
docker build -t enervision-backend:local $(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
|
db-up: ## Démarre la base PostgreSQL TimescaleDB
|
||||||
docker compose up -d db
|
docker compose up -d db
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
|||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
||||||
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs |
|
||||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||||
|
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||||
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
|
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
|
||||||
@@ -47,11 +48,13 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
|
|||||||
│ ├── migrations/ Migrations SQL versionnees
|
│ ├── migrations/ Migrations SQL versionnees
|
||||||
│ └── seeds/ Jeux de donnees de reference
|
│ └── seeds/ Jeux de donnees de reference
|
||||||
├── etl/airflow/
|
├── etl/airflow/
|
||||||
│ ├── dags/ DAGs d'ingestion et d'agregation
|
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique)
|
||||||
│ ├── plugins/ Operateurs et hooks maison
|
│ ├── plugins/ Operateurs et hooks maison
|
||||||
│ ├── include/ Requetes SQL et ressources des DAGs
|
│ ├── include/ Requetes SQL et ressources des DAGs
|
||||||
│ └── tests/ Tests d'integrite des DAGs
|
│ └── tests/ Tests d'integrite des DAGs
|
||||||
├── infra/terraform/
|
├── infra/
|
||||||
|
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
|
||||||
|
│ └── terraform/
|
||||||
│ ├── modules/ Modules reutilisables
|
│ ├── modules/ Modules reutilisables
|
||||||
│ └── environments/ Racines Terraform, une par environnement
|
│ └── environments/ Racines Terraform, une par environnement
|
||||||
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
|
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
|
||||||
@@ -98,6 +101,21 @@ Verifier que la base repond et que l'extension est chargee :
|
|||||||
curl -s localhost:8000/api/v1/health/ready
|
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
|
## Conventions
|
||||||
|
|
||||||
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ WORKDIR /app
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
uv sync --locked --no-install-project --no-dev
|
uv sync --locked --no-install-project --no-dev --no-build
|
||||||
|
|
||||||
|
# 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
|
COPY . /app
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
||||||
uv sync --locked --no-dev
|
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.14-slim AS runtime
|
FROM python:3.14-slim AS runtime
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
|||||||
| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` |
|
| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` |
|
||||||
| `/api/v1/recommendations` | Liste les recommandations | `lecteur` |
|
| `/api/v1/recommendations` | Liste les recommandations | `lecteur` |
|
||||||
| `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `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` |
|
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
|
||||||
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,11 @@ AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)]
|
|||||||
|
|
||||||
|
|
||||||
def get_recommendation_service(session: SessionDep) -> RecommendationService:
|
def get_recommendation_service(session: SessionDep) -> RecommendationService:
|
||||||
return RecommendationService(recommendations=RecommendationRepository(session))
|
return RecommendationService(
|
||||||
|
recommendations=RecommendationRepository(session),
|
||||||
|
alerts=AlertRepository(session),
|
||||||
|
transaction=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)]
|
RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)]
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ TAGS: Final[list[dict[str, Any]]] = [
|
|||||||
"name": "recommendations",
|
"name": "recommendations",
|
||||||
"description": (
|
"description": (
|
||||||
"Consultation des recommandations issues des alertes. Accessible à partir du rôle "
|
"Consultation des recommandations issues des alertes. Accessible à partir du rôle "
|
||||||
"`lecteur`."
|
"`lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
from app.api.deps import LecteurDep, RecommendationServiceDep
|
from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep
|
||||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses
|
||||||
from app.schemas.errors import ErrorResponse
|
from app.schemas.errors import ErrorResponse
|
||||||
from app.schemas.recommendation import RecommendationResponse
|
from app.schemas.recommendation import (
|
||||||
|
RecommendationGenerationResponse,
|
||||||
|
RecommendationResponse,
|
||||||
|
)
|
||||||
from app.services.recommendation import RecommendationNotFoundError
|
from app.services.recommendation import RecommendationNotFoundError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
REPONSES_GENERATION: Reponses = {**REPONSES_ADMIN, **REPONSE_VALIDATION}
|
||||||
|
|
||||||
REPONSES_INTROUVABLE: Reponses = {
|
REPONSES_INTROUVABLE: Reponses = {
|
||||||
**REPONSE_VALIDATION,
|
**REPONSE_VALIDATION,
|
||||||
404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."},
|
404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."},
|
||||||
@@ -38,3 +43,22 @@ async def get_recommendation(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
|
||||||
) from erreur
|
) from erreur
|
||||||
return RecommendationResponse.model_validate(recommendation)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ from app.core.hashing import build_hasher
|
|||||||
from app.core.roles import Role
|
from app.core.roles import Role
|
||||||
from app.db.session import get_session_factory
|
from app.db.session import get_session_factory
|
||||||
from app.main import create_app
|
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.repositories.user import UserRepository
|
||||||
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
|
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
|
||||||
|
from app.services.recommendation import RecommendationService
|
||||||
|
|
||||||
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
||||||
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
||||||
@@ -63,6 +66,22 @@ 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
|
# 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
|
# 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.
|
# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris.
|
||||||
@@ -109,6 +128,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
|
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
|
||||||
)
|
)
|
||||||
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT))
|
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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -152,6 +179,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(export_openapi(Path(arguments.output)))
|
print(export_openapi(Path(arguments.output)))
|
||||||
return 0
|
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)
|
mot_de_passe = read_password(generate=arguments.generate)
|
||||||
|
|
||||||
succes, message = asyncio.run(
|
succes, message = asyncio.run(
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
# 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +22,25 @@ from app.core.config import get_settings
|
|||||||
|
|
||||||
SOURCE_HISTORY = "api_history"
|
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:
|
def create_mock_api_client() -> httpx.AsyncClient:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -31,6 +58,53 @@ def create_mock_api_client() -> httpx.AsyncClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
async def fetch_sites(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
@@ -43,14 +117,32 @@ async def fetch_sites(
|
|||||||
if not isinstance(payload, list):
|
if not isinstance(payload, list):
|
||||||
raise ValueError("La réponse /api/v1/sites doit être une liste.")
|
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
|
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(
|
async def upsert_sites(
|
||||||
connection: AsyncConnection,
|
connection: AsyncConnection,
|
||||||
sites: list[dict[str, Any]],
|
sites: list[dict[str, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
if not sites:
|
rows = [build_site_row(site) for site in sites]
|
||||||
|
|
||||||
|
if not rows:
|
||||||
return
|
return
|
||||||
|
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
@@ -81,7 +173,7 @@ async def upsert_sites(
|
|||||||
status = EXCLUDED.status
|
status = EXCLUDED.status
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
sites,
|
rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +182,7 @@ async def fetch_readings(
|
|||||||
site_id: str,
|
site_id: str,
|
||||||
start_time: datetime,
|
start_time: datetime,
|
||||||
end_time: datetime,
|
end_time: datetime,
|
||||||
limit: int = 1000,
|
limit: int = MAX_LIMIT,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/v1/readings",
|
"/api/v1/readings",
|
||||||
@@ -109,30 +201,36 @@ async def fetch_readings(
|
|||||||
if not isinstance(payload, list):
|
if not isinstance(payload, list):
|
||||||
raise ValueError("La réponse /api/v1/readings doit être une liste.")
|
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
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def build_reading_row(
|
def build_reading_row(
|
||||||
reading: dict[str, Any],
|
reading: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
timestamp = datetime.fromisoformat(reading["timestamp"].replace("Z", "+00:00"))
|
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 {
|
return {
|
||||||
"site_id": reading["site_id"],
|
"site_id": read_text(reading, "site_id"),
|
||||||
"timestamp": timestamp,
|
"timestamp": parse_datetime(read_text(reading, "timestamp")),
|
||||||
"source": SOURCE_HISTORY,
|
"source": SOURCE_HISTORY,
|
||||||
"dataset_id": None,
|
"dataset_id": None,
|
||||||
"consumption_kw": reading.get("consumption_kw"),
|
**measures,
|
||||||
"consumption_kwh": reading.get("consumption_kwh"),
|
|
||||||
"consumption_euros": None,
|
"consumption_euros": None,
|
||||||
"voltage_v": reading.get("voltage_v"),
|
|
||||||
"current_a": reading.get("current_a"),
|
|
||||||
"power_factor": reading.get("power_factor"),
|
|
||||||
"temperature_celsius": reading.get("temperature_celsius"),
|
|
||||||
"humidity_percent": reading.get("humidity_percent"),
|
|
||||||
"solar_irradiance_wm2": None,
|
"solar_irradiance_wm2": None,
|
||||||
"is_working_hours": None,
|
"is_working_hours": None,
|
||||||
"data_quality": reading.get("data_quality"),
|
"data_quality": resolve_quality(reading.get("data_quality"), rejected),
|
||||||
"null_reasons": reading.get("null_reasons"),
|
"null_reasons": resolve_null_reasons(reading.get("null_reasons"), rejected),
|
||||||
"imputed_values": None,
|
"imputed_values": None,
|
||||||
"imputation_method": None,
|
"imputation_method": None,
|
||||||
"raw_data": json.dumps(
|
"raw_data": json.dumps(
|
||||||
@@ -142,6 +240,8 @@ def build_reading_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 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(
|
READING_INSERT = text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO reading (
|
INSERT INTO reading (
|
||||||
@@ -186,7 +286,8 @@ READING_INSERT = text(
|
|||||||
:imputation_method,
|
:imputation_method,
|
||||||
CAST(:raw_data AS jsonb)
|
CAST(:raw_data AS jsonb)
|
||||||
)
|
)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT (site_id, timestamp, source, (coalesce(dataset_id, 0)))
|
||||||
|
DO NOTHING
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,7 +314,7 @@ async def import_mock_api_history(
|
|||||||
all_readings: list[dict[str, Any]] = []
|
all_readings: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for site in sites:
|
for site in sites:
|
||||||
site_id = site["site_id"]
|
site_id = read_text(site, "site_id")
|
||||||
|
|
||||||
readings = await fetch_readings(
|
readings = await fetch_readings(
|
||||||
client=client,
|
client=client,
|
||||||
@@ -281,7 +382,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--limit",
|
"--limit",
|
||||||
type=int,
|
type=int,
|
||||||
default=1000,
|
default=MAX_LIMIT,
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -295,8 +396,8 @@ def parse_args() -> argparse.Namespace:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
if args.limit < 1 or args.limit > 1000:
|
if args.limit < 1 or args.limit > MAX_LIMIT:
|
||||||
raise ValueError("--limit doit être compris entre 1 et 1000.")
|
raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.")
|
||||||
|
|
||||||
if args.start_time >= args.end_time:
|
if args.start_time >= args.end_time:
|
||||||
raise ValueError("--start-time doit être antérieur à --end-time.")
|
raise ValueError("--start-time doit être antérieur à --end-time.")
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.energy import Recommendation
|
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:
|
class RecommendationRepository:
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
@@ -20,3 +33,19 @@ class RecommendationRepository:
|
|||||||
)
|
)
|
||||||
recommendation: Recommendation | None = await self._session.scalar(requete)
|
recommendation: Recommendation | None = await self._session.scalar(requete)
|
||||||
return recommendation
|
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
|
||||||
|
|||||||
@@ -12,3 +12,9 @@ class RecommendationResponse(BaseModel):
|
|||||||
explanation: str
|
explanation: str
|
||||||
rule_reference: str
|
rule_reference: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationGenerationResponse(BaseModel):
|
||||||
|
alerts_examined: int
|
||||||
|
recommendations_created: int
|
||||||
|
already_present: int
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
from app.models.energy import Recommendation
|
from app.models.energy import Recommendation
|
||||||
|
from app.repositories.alert import AlertRepository
|
||||||
from app.repositories.recommendation import RecommendationRepository
|
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):
|
class RecommendationError(Exception):
|
||||||
@@ -12,9 +20,24 @@ class RecommendationNotFoundError(RecommendationError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RapportGeneration:
|
||||||
|
alertes_examinees: int
|
||||||
|
recommandations_creees: int
|
||||||
|
deja_presentes: int
|
||||||
|
|
||||||
|
|
||||||
class RecommendationService:
|
class RecommendationService:
|
||||||
def __init__(self, *, recommendations: RecommendationRepository) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
recommendations: RecommendationRepository,
|
||||||
|
alerts: AlertRepository,
|
||||||
|
transaction: Transaction,
|
||||||
|
) -> None:
|
||||||
self._recommendations = recommendations
|
self._recommendations = recommendations
|
||||||
|
self._alerts = alerts
|
||||||
|
self._transaction = transaction
|
||||||
|
|
||||||
async def list_all(self) -> Sequence[Recommendation]:
|
async def list_all(self) -> Sequence[Recommendation]:
|
||||||
return await self._recommendations.list_all()
|
return await self._recommendations.list_all()
|
||||||
@@ -24,3 +47,16 @@ class RecommendationService:
|
|||||||
if recommendation is None:
|
if recommendation is None:
|
||||||
raise RecommendationNotFoundError(recommendation_id)
|
raise RecommendationNotFoundError(recommendation_id)
|
||||||
return recommendation
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 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})"
|
||||||
+108
-1
@@ -1453,6 +1453,90 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/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": {
|
"/api/v1/stats/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2344,6 +2428,29 @@
|
|||||||
],
|
],
|
||||||
"title": "ReadingSource"
|
"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": {
|
"RecommendationResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"recommendation_id": {
|
"recommendation_id": {
|
||||||
@@ -3153,7 +3260,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "recommendations",
|
"name": "recommendations",
|
||||||
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`."
|
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "stats",
|
"name": "stats",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = {
|
|||||||
("GET", "/api/v1/alerts"): Role.LECTEUR,
|
("GET", "/api/v1/alerts"): Role.LECTEUR,
|
||||||
("GET", "/api/v1/recommendations"): Role.LECTEUR,
|
("GET", "/api/v1/recommendations"): Role.LECTEUR,
|
||||||
("GET", "/api/v1/recommendations/{recommendation_id}"): 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/stats/summary"): Role.LECTEUR,
|
||||||
("GET", "/api/v1/readings"): Role.LECTEUR,
|
("GET", "/api/v1/readings"): Role.LECTEUR,
|
||||||
("GET", "/api/v1/predictions"): Role.LECTEUR,
|
("GET", "/api/v1/predictions"): Role.LECTEUR,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.api.deps import get_current_principal, get_recommendation_service
|
|||||||
from app.core.principal import Principal
|
from app.core.principal import Principal
|
||||||
from app.core.roles import AccountKind, Role
|
from app.core.roles import AccountKind, Role
|
||||||
from app.models.energy import Recommendation
|
from app.models.energy import Recommendation
|
||||||
from app.services.recommendation import RecommendationNotFoundError
|
from app.services.recommendation import RapportGeneration, RecommendationNotFoundError
|
||||||
|
|
||||||
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ class FauxService:
|
|||||||
def __init__(self, erreur: Exception | None = None) -> None:
|
def __init__(self, erreur: Exception | None = None) -> None:
|
||||||
self._erreur = erreur
|
self._erreur = erreur
|
||||||
self.recommendation = recommendation()
|
self.recommendation = recommendation()
|
||||||
|
self.site_demande: str | None = None
|
||||||
|
|
||||||
async def list_all(self) -> list[Recommendation]:
|
async def list_all(self) -> list[Recommendation]:
|
||||||
return [self.recommendation]
|
return [self.recommendation]
|
||||||
@@ -49,6 +50,10 @@ class FauxService:
|
|||||||
raise self._erreur
|
raise self._erreur
|
||||||
return self.recommendation
|
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
|
@pytest.fixture
|
||||||
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
|
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
|
||||||
@@ -142,3 +147,56 @@ async def test_get_recommendation_returns_404_when_the_session_finds_nothing(
|
|||||||
response = await client.get("/api/v1/recommendations/404")
|
response = await client.get("/api/v1/recommendations/404")
|
||||||
|
|
||||||
assert response.status_code == 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
|
||||||
|
|||||||
@@ -112,8 +112,10 @@ async def test_duplicate_reading_is_rejected_when_key_matches(
|
|||||||
)
|
)
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
@@ -147,9 +149,12 @@ async def test_invalid_reading_is_rejected_when_constraints_fail(
|
|||||||
}
|
}
|
||||||
values.update(changes)
|
values.update(changes)
|
||||||
|
|
||||||
|
statement = insert(Reading).values(**values)
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(insert(Reading).values(**values))
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
async def test_prediction_requires_period_when_energy_is_predicted(
|
async def test_prediction_requires_period_when_energy_is_predicted(
|
||||||
@@ -164,8 +169,10 @@ async def test_prediction_requires_period_when_energy_is_predicted(
|
|||||||
model_reference="test-model/1",
|
model_reference="test-model/1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
@@ -212,10 +219,7 @@ async def test_alert_rejects_prediction_when_site_differs(
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
statement = insert(Alert).values(
|
||||||
async with data_connection.begin_nested():
|
|
||||||
await data_connection.execute(
|
|
||||||
insert(Alert).values(
|
|
||||||
source_alert_id=str(uuid4()),
|
source_alert_id=str(uuid4()),
|
||||||
site_id=other_site,
|
site_id=other_site,
|
||||||
source="enervision",
|
source="enervision",
|
||||||
@@ -226,7 +230,11 @@ async def test_alert_rejects_prediction_when_site_differs(
|
|||||||
prediction_id=prediction_id,
|
prediction_id=prediction_id,
|
||||||
raw_data={},
|
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(
|
async def test_recommendation_is_unique_when_alert_and_rule_match(
|
||||||
@@ -256,6 +264,8 @@ async def test_recommendation_is_unique_when_alert_and_rule_match(
|
|||||||
)
|
)
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|||||||
@@ -106,13 +106,15 @@ def test_validate_source_accepts_valid_dataset():
|
|||||||
def test_validate_source_rejects_missing_column():
|
def test_validate_source_rejects_missing_column():
|
||||||
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
||||||
|
|
||||||
|
metadata = make_metadata()
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="Colonnes obligatoires absentes",
|
match="Colonnes obligatoires absentes",
|
||||||
):
|
):
|
||||||
validate_source(
|
validate_source(
|
||||||
frame,
|
frame,
|
||||||
make_metadata(),
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,13 +126,15 @@ def test_validate_source_rejects_duplicates():
|
|||||||
"timestamp",
|
"timestamp",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
metadata = make_metadata()
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="doublons",
|
match="doublons",
|
||||||
):
|
):
|
||||||
validate_source(
|
validate_source(
|
||||||
frame,
|
frame,
|
||||||
make_metadata(),
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,13 +143,15 @@ def test_validate_source_rejects_unknown_site():
|
|||||||
|
|
||||||
frame.loc[1, "site_id"] = "SITE999"
|
frame.loc[1, "site_id"] = "SITE999"
|
||||||
|
|
||||||
|
metadata = make_metadata()
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="Sites incohérents",
|
match="Sites incohérents",
|
||||||
):
|
):
|
||||||
validate_source(
|
validate_source(
|
||||||
frame,
|
frame,
|
||||||
make_metadata(),
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
import app.etl.mock_api_import as mock_api_import
|
import app.etl.mock_api_import as mock_api_import
|
||||||
from app.etl.mock_api_import import (
|
from app.etl.mock_api_import import (
|
||||||
|
MAX_SITES,
|
||||||
READING_INSERT,
|
READING_INSERT,
|
||||||
SOURCE_HISTORY,
|
SOURCE_HISTORY,
|
||||||
build_reading_batch,
|
build_reading_batch,
|
||||||
build_reading_row,
|
build_reading_row,
|
||||||
|
build_site_row,
|
||||||
fetch_readings,
|
fetch_readings,
|
||||||
fetch_sites,
|
fetch_sites,
|
||||||
upsert_sites,
|
upsert_sites,
|
||||||
@@ -73,6 +75,26 @@ async def test_fetch_sites_returns_sites() -> None:
|
|||||||
assert sites[0]["site_type"] == "office"
|
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:
|
async def test_fetch_readings_sends_expected_query_parameters() -> None:
|
||||||
captured_params: dict[str, str] = {}
|
captured_params: dict[str, str] = {}
|
||||||
|
|
||||||
@@ -576,6 +598,148 @@ def test_main_runs_import(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
@pytest.mark.integration
|
||||||
async def test_reading_insert_is_idempotent(
|
async def test_reading_insert_is_idempotent(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -620,3 +784,51 @@ async def test_reading_insert_is_idempotent(
|
|||||||
assert result.scalar_one() == 1
|
assert result.scalar_one() == 1
|
||||||
|
|
||||||
await session.rollback()
|
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"
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ async def test_the_database_refuses_to_mutate_the_audit_log(
|
|||||||
) -> None:
|
) -> None:
|
||||||
await une_ligne(session)
|
await une_ligne(session)
|
||||||
|
|
||||||
|
requete = text(instruction)
|
||||||
|
|
||||||
with pytest.raises(DBAPIError, match="ajout seul"):
|
with pytest.raises(DBAPIError, match="ajout seul"):
|
||||||
await session.execute(text(instruction))
|
await session.execute(requete)
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -131,11 +131,14 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
|||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
empreinte = fingerprint_refresh(secret)
|
||||||
|
expiration = datetime.now(UTC) + DUREE
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
await depot.create(
|
await depot.create(
|
||||||
user_id=compte,
|
user_id=compte,
|
||||||
token_hash=fingerprint_refresh(secret),
|
token_hash=empreinte,
|
||||||
expires_at=datetime.now(UTC) + DUREE,
|
expires_at=expiration,
|
||||||
client_ip=None,
|
client_ip=None,
|
||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import pytest
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.energy import Alert, Recommendation, Site
|
from app.models.energy import Alert, Recommendation, Site
|
||||||
from app.repositories.recommendation import RecommendationRepository
|
from app.repositories import recommendation as module_recommendation
|
||||||
|
from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
@@ -83,3 +84,59 @@ async def test_list_all_returns_the_recommendations_sorted_by_identifier(
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
assert identifiants == sorted(identifiants)
|
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,12 +178,16 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
|||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
famille = uuid.uuid4()
|
||||||
|
empreinte = fingerprint_refresh(secret)
|
||||||
|
expiration = datetime.now(UTC) + DUREE
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
await depot.create(
|
await depot.create(
|
||||||
user_id=compte,
|
user_id=compte,
|
||||||
family_id=uuid.uuid4(),
|
family_id=famille,
|
||||||
token_hash=fingerprint_refresh(secret),
|
token_hash=empreinte,
|
||||||
expires_at=datetime.now(UTC) + DUREE,
|
expires_at=expiration,
|
||||||
client_ip=None,
|
client_ip=None,
|
||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,14 +31,12 @@ async def test_the_database_refuses_an_email_written_in_upper_case(
|
|||||||
) -> None:
|
) -> None:
|
||||||
saisie = adresse().upper()
|
saisie = adresse().upper()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
requete = text(
|
||||||
await session.execute(
|
"insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
|
||||||
text(
|
|
||||||
"insert into app_user (email, password_hash, role) "
|
|
||||||
"values (:e, '$argon2id$x', 'lecteur')"
|
|
||||||
),
|
|
||||||
{"e": saisie},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await session.execute(requete, {"e": saisie})
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,13 +116,11 @@ async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
|
|||||||
async def test_list_history_raises_when_start_is_after_end() -> None:
|
async def test_list_history_raises_when_start_is_after_end() -> None:
|
||||||
service = ReadingService(readings=FakeRepository([]))
|
service = ReadingService(readings=FakeRepository([]))
|
||||||
|
|
||||||
|
debut = datetime(2026, 9, 2, tzinfo=UTC)
|
||||||
|
fin = datetime(2026, 9, 1, tzinfo=UTC)
|
||||||
|
|
||||||
with pytest.raises(FenetreInverseeError):
|
with pytest.raises(FenetreInverseeError):
|
||||||
await service.list_history(
|
await service.list_history(start=debut, end=fin, limit=500, offset=0)
|
||||||
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:
|
async def test_list_history_raises_when_start_equals_end() -> None:
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.models.energy import Recommendation
|
from app.models.energy import Alert, Recommendation
|
||||||
|
from app.repositories.recommendation import NouvelleRecommandation
|
||||||
from app.services.recommendation import RecommendationNotFoundError, RecommendationService
|
from app.services.recommendation import RecommendationNotFoundError, RecommendationService
|
||||||
|
|
||||||
|
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
def recommendation(recommendation_id: int = 1) -> Recommendation:
|
def recommendation(recommendation_id: int = 1) -> Recommendation:
|
||||||
return Recommendation(
|
return Recommendation(
|
||||||
@@ -13,13 +17,33 @@ def recommendation(recommendation_id: int = 1) -> Recommendation:
|
|||||||
action="Vérifier la consommation",
|
action="Vérifier la consommation",
|
||||||
explanation="Pic détecté",
|
explanation="Pic détecté",
|
||||||
rule_reference="spike-v1",
|
rule_reference="spike-v1",
|
||||||
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
created_at=MOMENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def alerte(alert_id: int = 1, site_id: str = "SITE001", severity: str = "high") -> Alert:
|
||||||
|
return Alert(
|
||||||
|
alert_id=alert_id,
|
||||||
|
source_alert_id=f"ALR-{alert_id}",
|
||||||
|
site_id=site_id,
|
||||||
|
source="api_mock",
|
||||||
|
timestamp=MOMENT,
|
||||||
|
type="spike",
|
||||||
|
severity=severity,
|
||||||
|
message="Pic de consommation",
|
||||||
|
value=None,
|
||||||
|
threshold=None,
|
||||||
|
metric=None,
|
||||||
|
prediction_id=None,
|
||||||
|
raw_data={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeRepository:
|
class FakeRepository:
|
||||||
def __init__(self, recommendations: list[Recommendation]) -> None:
|
def __init__(self, recommendations: list[Recommendation], creees: int | None = None) -> None:
|
||||||
self._recommendations = recommendations
|
self._recommendations = recommendations
|
||||||
|
self._creees = creees
|
||||||
|
self.recues: list[NouvelleRecommandation] = []
|
||||||
|
|
||||||
async def list_all(self) -> list[Recommendation]:
|
async def list_all(self) -> list[Recommendation]:
|
||||||
return self._recommendations
|
return self._recommendations
|
||||||
@@ -29,27 +53,111 @@ class FakeRepository:
|
|||||||
(r for r in self._recommendations if r.recommendation_id == recommendation_id), None
|
(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
|
||||||
|
|
||||||
async def test_list_all_returns_the_repository_recommendations() -> None:
|
|
||||||
service = RecommendationService(
|
class FakeAlertRepository:
|
||||||
recommendations=FakeRepository([recommendation(1), recommendation(2)])
|
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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
recommendations = await service.list_all()
|
|
||||||
|
async def test_list_all_returns_the_repository_recommendations() -> None:
|
||||||
|
depot = FakeRepository([recommendation(1), recommendation(2)])
|
||||||
|
|
||||||
|
recommendations = await service(recommendations=depot).list_all()
|
||||||
|
|
||||||
assert [r.recommendation_id for r in recommendations] == [1, 2]
|
assert [r.recommendation_id for r in recommendations] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
async def test_get_by_id_returns_the_matching_recommendation() -> None:
|
async def test_get_by_id_returns_the_matching_recommendation() -> None:
|
||||||
service = RecommendationService(recommendations=FakeRepository([recommendation(1)]))
|
trouve = await service(recommendations=FakeRepository([recommendation(1)])).get_by_id(1)
|
||||||
|
|
||||||
trouve = await service.get_by_id(1)
|
|
||||||
|
|
||||||
assert trouve.recommendation_id == 1
|
assert trouve.recommendation_id == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
|
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
|
||||||
service = RecommendationService(recommendations=FakeRepository([]))
|
|
||||||
|
|
||||||
with pytest.raises(RecommendationNotFoundError):
|
with pytest.raises(RecommendationNotFoundError):
|
||||||
await service.get_by_id(404)
|
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
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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",
|
||||||
|
}
|
||||||
@@ -235,5 +235,7 @@ async def test_every_operation_refuses_an_unknown_account(action: str) -> None:
|
|||||||
if action == "set_active":
|
if action == "set_active":
|
||||||
arguments["is_active"] = False
|
arguments["is_active"] = False
|
||||||
|
|
||||||
|
methode = getattr(attirail.service, action)
|
||||||
|
|
||||||
with pytest.raises(UserNotFoundError):
|
with pytest.raises(UserNotFoundError):
|
||||||
await getattr(attirail.service, action)(**arguments)
|
await methode(**arguments)
|
||||||
|
|||||||
@@ -19,13 +19,17 @@ def test_build_parser_reads_the_create_admin_arguments() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_build_parser_requires_a_subcommand() -> None:
|
def test_build_parser_requires_a_subcommand() -> None:
|
||||||
|
parser = cli.build_parser()
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cli.build_parser().parse_args([])
|
parser.parse_args([])
|
||||||
|
|
||||||
|
|
||||||
def test_build_parser_requires_an_email() -> None:
|
def test_build_parser_requires_an_email() -> None:
|
||||||
|
parser = cli.build_parser()
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cli.build_parser().parse_args(["create-admin"])
|
parser.parse_args(["create-admin"])
|
||||||
|
|
||||||
|
|
||||||
def test_read_password_generates_a_long_secret_when_asked(
|
def test_read_password_generates_a_long_secret_when_asked(
|
||||||
@@ -118,3 +122,30 @@ def test_main_exports_the_contract_without_asking_for_a_password(
|
|||||||
assert code == 0
|
assert code == 0
|
||||||
assert destination.exists()
|
assert destination.exists()
|
||||||
assert str(destination) in capsys.readouterr().out
|
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
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"optimization": {
|
||||||
|
"styles": {
|
||||||
|
"inlineCritical": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
|
|||||||
@@ -23,4 +23,11 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
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),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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,6 +10,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard__actions">
|
<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>
|
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
||||||
<ev-button
|
<ev-button
|
||||||
class="logout-button"
|
class="logout-button"
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ h2 {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card--link {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card__label {
|
.card__label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -170,8 +170,11 @@ describe('Dashboard', () => {
|
|||||||
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
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 statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||||
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
|
const authMock = {
|
||||||
|
logout: vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
|
};
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [Dashboard],
|
imports: [Dashboard],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -201,6 +204,7 @@ describe('Dashboard', () => {
|
|||||||
const authMock = {
|
const authMock = {
|
||||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||||
clearSession: vi.fn(),
|
clearSession: vi.fn(),
|
||||||
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
};
|
};
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [Dashboard],
|
imports: [Dashboard],
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
|||||||
export class Dashboard implements OnInit {
|
export class Dashboard implements OnInit {
|
||||||
private statsService = inject(StatsService);
|
private statsService = inject(StatsService);
|
||||||
private alertsService = inject(AlertsService);
|
private alertsService = inject(AlertsService);
|
||||||
|
public auth = inject(AuthService);
|
||||||
private predictionsService = inject(PredictionsService);
|
private predictionsService = inject(PredictionsService);
|
||||||
private auth = inject(AuthService);
|
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 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:
|
||||||
+96
-1
@@ -5,6 +5,42 @@
|
|||||||
|
|
||||||
name: enervision
|
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
|
||||||
|
- ./data/raw:/opt/data/raw:ro
|
||||||
|
- airflow_logs:/opt/airflow/logs
|
||||||
|
- airflow_ml_state:/opt/ml/state
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: timescale/timescaledb-ha:pg17
|
image: timescale/timescaledb-ha:pg17
|
||||||
@@ -19,6 +55,7 @@ services:
|
|||||||
- pgdata:/home/postgres/pgdata/data
|
- pgdata:/home/postgres/pgdata/data
|
||||||
- ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro
|
- ./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/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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -68,9 +105,67 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build: ./apps/frontend
|
build: ./apps/frontend
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:80"
|
- "${FRONTEND_PORT:-3000}:3000"
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
airflow_logs:
|
||||||
|
airflow_ml_state:
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# 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
|
||||||
@@ -11,3 +11,7 @@
|
|||||||
| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
|
| [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 |
|
| [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 |
|
| [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 |
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 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`.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 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.
|
||||||
@@ -46,6 +46,7 @@ flowchart TB
|
|||||||
navigateur["Navigateur"]
|
navigateur["Navigateur"]
|
||||||
|
|
||||||
subgraph machine["Machine on-premise"]
|
subgraph machine["Machine on-premise"]
|
||||||
|
proxy["Reverse proxy Nginx<br/>:80 et :443"]
|
||||||
front["Frontend Angular 22<br/>apps/frontend"]
|
front["Frontend Angular 22<br/>apps/frontend"]
|
||||||
api["API FastAPI<br/>apps/backend"]
|
api["API FastAPI<br/>apps/backend"]
|
||||||
db[("PostgreSQL 17<br/>TimescaleDB")]
|
db[("PostgreSQL 17<br/>TimescaleDB")]
|
||||||
@@ -54,10 +55,12 @@ flowchart TB
|
|||||||
grafana["Grafana"]
|
grafana["Grafana"]
|
||||||
end
|
end
|
||||||
|
|
||||||
navigateur --> front
|
navigateur --> proxy
|
||||||
|
proxy --> front
|
||||||
|
proxy --> api
|
||||||
front -.-> api
|
front -.-> api
|
||||||
api --> db
|
api --> db
|
||||||
airflow -.-> db
|
airflow --> db
|
||||||
prom -.-> api
|
prom -.-> api
|
||||||
grafana -.-> db
|
grafana -.-> db
|
||||||
grafana -.-> prom
|
grafana -.-> prom
|
||||||
@@ -67,6 +70,12 @@ 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
|
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||||
[30-frontend.md](30-frontend.md).
|
[30-frontend.md](30-frontend.md).
|
||||||
|
|
||||||
|
Le lien `airflow --> db` est maintenant en trait plein : quatre 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), et `historical_import` pour l'ingestion du dataset
|
||||||
|
historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des
|
||||||
|
deux sources restent à compléter dans l'issue #15.
|
||||||
|
|
||||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
||||||
collecteur ne vient le lire.
|
collecteur ne vient le lire.
|
||||||
|
|
||||||
@@ -77,15 +86,18 @@ collecteur ne vient le lire.
|
|||||||
| 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) |
|
| 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 |
|
| 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 |
|
||||||
| 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`) |
|
| 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`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites |
|
| 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 | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
|
| 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 |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
|
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
|
| 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) |
|
||||||
|
|
||||||
## Flux bout en bout
|
## Flux bout en bout
|
||||||
|
|
||||||
Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base.
|
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).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -137,6 +149,11 @@ consolidée.
|
|||||||
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
|
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.
|
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
|
||||||
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
|
- **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`
|
- **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
|
sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les
|
||||||
`.example`.
|
`.example`.
|
||||||
@@ -150,13 +167,12 @@ consolidée.
|
|||||||
arrêteraient une application compromise. Même raison de report.
|
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
|
- **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.
|
agir sur le site B. C'est la limite connue du modèle.
|
||||||
- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore.
|
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi
|
||||||
- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas
|
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME
|
||||||
l'infrastructure.
|
est livré et documenté mais pas exercé.
|
||||||
- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD.
|
- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place
|
||||||
- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP
|
(`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir
|
||||||
simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir
|
[50-cicd.md](50-cicd.md).
|
||||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
|
||||||
|
|
||||||
## Décisions structurantes
|
## Décisions structurantes
|
||||||
|
|
||||||
@@ -165,3 +181,9 @@ Elles vivent dans `../adr/`, pas ici.
|
|||||||
| ADR | Objet |
|
| ADR | Objet |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [0001](../adr/0001-postgresql-timescaledb.md) | PostgreSQL 17 avec l'extension TimescaleDB, et la frontière `db/` vs `alembic/` |
|
| [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 |
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# Infrastructure
|
# Infrastructure
|
||||||
|
|
||||||
Deux topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
|
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 les deux.
|
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.
|
||||||
|
|
||||||
| Topologie | Sert à | Statut |
|
| Topologie | Sert à | Statut |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
||||||
| k3s single-node | Déployer sur le serveur on-premise | `En cours` |
|
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
|
||||||
|
| k3s single-node | Cible à terme | `En cours` |
|
||||||
|
|
||||||
## Poste de développement
|
## Poste de développement
|
||||||
|
|
||||||
@@ -40,15 +41,141 @@ 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
|
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.
|
port 8000, ils ne se lancent donc pas ensemble.
|
||||||
|
|
||||||
Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
|
Trois 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
|
- `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.
|
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
|
- `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
|
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).
|
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).
|
||||||
|
|
||||||
## Cible de déploiement
|
### Airflow (issues #115, #116 et #119)
|
||||||
|
|
||||||
|
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` |
|
||||||
|
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
||||||
|
|
||||||
|
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
|
||||||
|
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
|
||||||
|
`./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les
|
||||||
|
modifier.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
||||||
été appliqué.
|
été appliqué.
|
||||||
@@ -104,6 +231,8 @@ 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` |
|
| `*.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` |
|
| 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/` |
|
| 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
|
## Ports et noms
|
||||||
|
|
||||||
@@ -112,12 +241,16 @@ 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é 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` |
|
| 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 |
|
| API | `8000` | Identique en conteneur et hors conteneur |
|
||||||
| Frontend, `ng serve` | `4200` | Valeur par défaut d'`APP_CORS_ORIGINS`. Le compose n'a aucun service frontend |
|
| 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 |
|
||||||
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
||||||
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
| 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 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 entre les deux topologies
|
## Le trou vers k3s
|
||||||
|
|
||||||
Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose
|
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
|
construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première
|
||||||
@@ -125,7 +258,11 @@ question à trancher, avant toute ressource Kubernetes.
|
|||||||
|
|
||||||
## Questions ouvertes
|
## Questions ouvertes
|
||||||
|
|
||||||
- **Quel ingress** remplace Traefik, et qui termine le TLS.
|
- **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 registre d'images**, et comment il est alimenté sans CI.
|
- **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
|
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
|
||||||
cluster ou à côté.
|
cluster ou à côté.
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ 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/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` | Liste les recommandations. `lecteur` | 401, 403, 500 |
|
||||||
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 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/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/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/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 |
|
||||||
@@ -192,7 +193,22 @@ mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend
|
|||||||
plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte
|
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
|
`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.
|
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.
|
[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,
|
`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/
|
donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/
|
||||||
@@ -240,12 +256,19 @@ auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spi
|
|||||||
explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul
|
explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul
|
||||||
instant réel, pas une variation).
|
instant réel, pas une variation).
|
||||||
|
|
||||||
Comme `enervision_ml.score`, la détection est un script lancé à la main, pas encore ordonnancé par
|
La détection s'exécute dans `apps/backend`, puisque les règles s'appuient sur les repositories ORM
|
||||||
Airflow : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans
|
de l'API plutôt que sur une connexion SQL directe (contrairement à
|
||||||
`apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une
|
`app/etl/historical_import.py`) : `uv run python -m app.detection.internal_alerts [--site-id ...]
|
||||||
connexion SQL directe (contrairement à `app/etl/historical_import.py`). Cette issue (#104)
|
[--now ...]`, ou `make detect-alerts`. Cette issue (#104) débloquait #38 (moteur de règles pour
|
||||||
débloquait #38 (moteur de règles pour recommandations), dont la FK `alert_id` `NOT NULL` n'avait
|
recommandations), dont la FK `alert_id` `NOT NULL` n'avait jusqu'ici rien à référencer côté
|
||||||
jusqu'ici rien à référencer côté `source="enervision"`.
|
`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`
|
### `/health/ready`
|
||||||
|
|
||||||
@@ -374,9 +397,12 @@ Le reste, par ordre de surface :
|
|||||||
de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL.
|
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`,
|
- 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
|
`Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au
|
||||||
terminateur TLS, que l'application ne connaît pas.
|
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)).
|
||||||
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
|
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
|
||||||
- Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative.
|
- 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.
|
||||||
|
|
||||||
## Observabilité
|
## Observabilité
|
||||||
|
|
||||||
|
|||||||
@@ -97,11 +97,11 @@ En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost
|
|||||||
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
||||||
`apiUrl` relatif, `/api/v1`.
|
`apiUrl` relatif, `/api/v1`.
|
||||||
|
|
||||||
En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif
|
En production, `environment.ts` porte lui aussi un `apiUrl` relatif (`/api/v1`) plutôt qu'une URL
|
||||||
(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur
|
absolue : la dette qui pointait en dur sur `http://localhost:8000/api/v1` a été corrigée. Un build
|
||||||
`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...`
|
de production sert donc l'appel `/api/v1/...` sur son propre origin, et c'est le **reverse proxy**
|
||||||
sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le
|
qui route `/api` vers le backend : `location /api/` dans `infra/proxy/conf.d/enervision.conf`, voir
|
||||||
backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md).
|
[10-infra.md](10-infra.md) et l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
|
|
||||||
## Exécution
|
## Exécution
|
||||||
|
|
||||||
@@ -118,13 +118,14 @@ le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.1
|
|||||||
tester ni construire le frontend.
|
tester ni construire le frontend.
|
||||||
|
|
||||||
Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`,
|
Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`,
|
||||||
englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en
|
englobées par `install` et `dev`). En développement il tourne directement via `npm`, depuis
|
||||||
développement il tourne toujours directement via `npm`, depuis `apps/frontend`. Le port 4200
|
`apps/frontend` : le port 4200 n'apparaît dans le compose que comme valeur par défaut
|
||||||
n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend.
|
d'`APP_CORS_ORIGINS`, côté backend.
|
||||||
|
|
||||||
Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans
|
Le service `frontend` du `docker-compose.yml` sert le build statique par le nginx de
|
||||||
`CMD` : il construit sans rien servir. Le `README.md` de l'application demande un multi-étage
|
`apps/frontend/Dockerfile`, multi-étage, qui **écoute sur 3000**. En déploiement il n'est plus
|
||||||
avec un service statique, il reste à écrire.
|
publié du tout : le reverse proxy est seul à sortir sur le réseau, et l'atteint par le réseau
|
||||||
|
Compose.
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
@@ -133,6 +134,13 @@ avec un service statique, il reste à écrire.
|
|||||||
`/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
|
`/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
|
||||||
rafraîchissement sur 401. Détail complet dans
|
rafraîchissement sur 401. Détail complet dans
|
||||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
[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.
|
||||||
|
|
||||||
## Tests
|
## 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
|
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.
|
**même origine** et le cookie marche sans rien configurer.
|
||||||
|
|
||||||
En production, `src/environments/environment.ts` contient encore le gabarit
|
En déploiement, les deux conditions sont désormais remplies par le reverse proxy
|
||||||
`http://localhost:8000/api/v1`, en HTTP simple et sur une autre origine. **Dans cet état, aucun
|
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)) : `environment.ts` porte un
|
||||||
cookie `Secure` ne sera posé et l'authentification ne fonctionnera pas.**
|
`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.
|
||||||
|
|
||||||
Deux corrections, à faire avant la démonstration :
|
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.
|
||||||
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
|
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 le proxy masque.
|
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que la même origine masque.
|
||||||
|
|
||||||
## Origines autorisées
|
## Origines autorisées
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ d'énergie, dont l'hypertable `reading`.
|
|||||||
Les sections marquées `Fait` relèvent du code déjà implémenté. Les sections marquées `Cible`
|
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.
|
décrivent les éléments prévus mais pas encore réalisés.
|
||||||
|
|
||||||
L'ingestion des deux sources de données du MVP est maintenant implémentée. L'orchestration
|
L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le dataset CSV/JSON et
|
||||||
Airflow, les agrégats continus, la compression et la rétention restent des cibles.
|
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.
|
||||||
|
|
||||||
## Trois emplacements, trois rôles
|
## Trois emplacements, trois rôles
|
||||||
|
|
||||||
@@ -96,8 +100,8 @@ 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 flèches pointillées représentent les éléments encore prévus comme cibles.
|
||||||
|
|
||||||
Les lectures futures de l'API et de Grafana visent l'agrégat continu plutôt que la table brute
|
Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout
|
||||||
lorsque cette partie TimescaleDB sera mise en place.
|
l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront.
|
||||||
|
|
||||||
## Tables d'authentification
|
## Tables d'authentification
|
||||||
|
|
||||||
@@ -170,21 +174,28 @@ erDiagram
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Plusieurs choix de modélisation portent une intention précise :
|
Six 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
|
- **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de
|
||||||
`CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif.
|
`CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition
|
||||||
- **`credentials_changed_at`, une seule colonne**, couvre notamment le changement de mot de passe,
|
au rôle PostgreSQL qui portera le cantonnement de l'ETL.
|
||||||
le changement de rôle et la désactivation.
|
- **`credentials_changed_at`, une seule colonne**, couvre le changement de mot de passe, le
|
||||||
- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation.
|
changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui
|
||||||
- **`audit_log.actor_id` n'a aucune clé étrangère** afin de conserver les informations d'audit
|
lit un audit.
|
||||||
même si l'entité d'origine évolue.
|
- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. S'il
|
||||||
- `password_reset_token` ne stocke que l'empreinte du jeton et jamais sa valeur directement.
|
glissait, la promesse de sept jours serait fictive et une session active ne finirait jamais.
|
||||||
- `password_reset_attempt` est séparée de `audit_log`, car son volume peut être piloté
|
- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont
|
||||||
par des demandes externes répétées.
|
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 des déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`.
|
`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est
|
||||||
Elle n'est donc **pas** une hypertable.
|
donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils
|
||||||
|
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
|
||||||
|
piloté par l'attaquant.
|
||||||
|
|
||||||
## Gabarit de révision créant une hypertable
|
## Gabarit de révision créant une hypertable
|
||||||
|
|
||||||
@@ -234,7 +245,8 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà.
|
|||||||
|
|
||||||
## Questions ouvertes
|
## Questions ouvertes
|
||||||
|
|
||||||
Elles portent maintenant principalement sur l'exploitation du schéma :
|
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.
|
||||||
|
|
||||||
- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure.
|
- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure.
|
||||||
- **Quels agrégats continus** créer et sur quelles fenêtres.
|
- **Quels agrégats continus** créer et sur quelles fenêtres.
|
||||||
@@ -279,6 +291,12 @@ Les anomalies historiques décrites dans les JSON sont conservées dans `dataset
|
|||||||
|
|
||||||
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
|
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.
|
||||||
|
|
||||||
### Relations entre les tables
|
### Relations entre les tables
|
||||||
|
|
||||||
- Un site possède plusieurs mesures, prévisions et alertes.
|
- Un site possède plusieurs mesures, prévisions et alertes.
|
||||||
@@ -287,7 +305,7 @@ Elles servent à l'analyse des données et ne sont pas considérées comme des a
|
|||||||
- Une alerte peut être associée à une prévision du même site.
|
- Une alerte peut être associée à une prévision du même site.
|
||||||
- Une alerte peut donner lieu à plusieurs recommandations.
|
- Une alerte peut donner lieu à plusieurs recommandations.
|
||||||
|
|
||||||
# Ingestion des données historiques
|
## Ingestion des données historiques
|
||||||
|
|
||||||
Statut : `Fait`.
|
Statut : `Fait`.
|
||||||
|
|
||||||
@@ -301,7 +319,7 @@ Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisat
|
|||||||
|
|
||||||
Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
|
Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
|
||||||
|
|
||||||
## Architecture du flux historique
|
### Architecture du flux historique
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Dataset CSV + métadonnées JSON
|
Dataset CSV + métadonnées JSON
|
||||||
@@ -351,7 +369,7 @@ source = "csv"
|
|||||||
dataset_id = identifiant du dataset
|
dataset_id = identifiant du dataset
|
||||||
```
|
```
|
||||||
|
|
||||||
## Résultats validés pour l'historique
|
### Résultats validés pour l'historique
|
||||||
|
|
||||||
Le chargement de référence a permis d'obtenir :
|
Le chargement de référence a permis d'obtenir :
|
||||||
|
|
||||||
@@ -366,7 +384,7 @@ aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à
|
|||||||
La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline
|
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`.
|
est disponible dans `etl/README.md`.
|
||||||
|
|
||||||
# Ingestion depuis l'API Mock
|
## Ingestion depuis l'API Mock
|
||||||
|
|
||||||
Statut : `Fait`.
|
Statut : `Fait`.
|
||||||
|
|
||||||
@@ -378,7 +396,7 @@ Le traitement est implémenté dans :
|
|||||||
apps/backend/app/etl/mock_api_import.py
|
apps/backend/app/etl/mock_api_import.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Endpoints utilisés
|
### Endpoints utilisés
|
||||||
|
|
||||||
Le pipeline récupère les informations des sites depuis :
|
Le pipeline récupère les informations des sites depuis :
|
||||||
|
|
||||||
@@ -410,7 +428,7 @@ Les paramètres de ligne de commande disponibles pour l'import sont :
|
|||||||
--dry-run
|
--dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Flux d'ingestion API Mock
|
### Flux d'ingestion API Mock
|
||||||
|
|
||||||
```text
|
```text
|
||||||
API Mock
|
API Mock
|
||||||
@@ -454,7 +472,32 @@ La réponse source reçue depuis l'API est conservée dans :
|
|||||||
raw_data
|
raw_data
|
||||||
```
|
```
|
||||||
|
|
||||||
## Qualité des données de l'API Mock
|
### 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 valeurs `NULL` ne sont pas remplacées pendant l'ingestion.
|
||||||
|
|
||||||
@@ -475,7 +518,7 @@ imputed_values = NULL
|
|||||||
imputation_method = NULL
|
imputation_method = NULL
|
||||||
```
|
```
|
||||||
|
|
||||||
## Validation de l'import API Mock
|
### Validation de l'import API Mock
|
||||||
|
|
||||||
Un scénario de validation a été exécuté pour les 7 sites sur la période :
|
Un scénario de validation a été exécuté pour les 7 sites sur la période :
|
||||||
|
|
||||||
@@ -526,7 +569,7 @@ Les tests automatisés couvrent également :
|
|||||||
- la conservation des données sources ;
|
- la conservation des données sources ;
|
||||||
- l'idempotence en base.
|
- l'idempotence en base.
|
||||||
|
|
||||||
# Évolution prévue
|
## Évolution prévue
|
||||||
|
|
||||||
La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow.
|
La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# 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`.
|
||||||
@@ -15,10 +15,15 @@ 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
||||||
|
|
||||||
L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents
|
La CI/CD a désormais son document : cinq workflows et seize jobs, c'est assez de matière pour
|
||||||
ci-dessus, tant que `monitoring/` et `etl/airflow/` ne contiennent que des `.gitkeep`. Elles en
|
qu'une section de plus dans une autre vue devienne illisible. L'observabilité, elle, n'en a
|
||||||
sortiront le jour où elles auront de la matière. Un fichier vide de plus n'aide personne.
|
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).
|
||||||
|
|
||||||
La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans
|
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
|
[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la
|
||||||
|
|||||||
@@ -41,22 +41,27 @@ 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
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.
|
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
|
## Non couvert, et pourquoi
|
||||||
|
|
||||||
| Item | État | Raison |
|
| 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. |
|
| **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. |
|
| **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. |
|
||||||
| **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** | **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. |
|
||||||
| **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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
||||||
| **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. |
|
| **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
|
## Ce qu'il faut répondre, et ne pas répondre
|
||||||
|
|||||||
+72
-28
@@ -81,9 +81,9 @@ L'API Mock est utilisée pour compléter les données historiques avec des mesur
|
|||||||
| mypy | Vérification du typage |
|
| mypy | Vérification du typage |
|
||||||
| Pytest | Tests automatisés |
|
| Pytest | Tests automatisés |
|
||||||
|
|
||||||
# Import du dataset historique
|
## Import du dataset historique
|
||||||
|
|
||||||
## Fonctionnement du pipeline historique
|
### Fonctionnement du pipeline historique
|
||||||
|
|
||||||
Le script d'import se trouve dans :
|
Le script d'import se trouve dans :
|
||||||
|
|
||||||
@@ -115,14 +115,14 @@ CSV + métadonnées JSON
|
|||||||
PostgreSQL / TimescaleDB
|
PostgreSQL / TimescaleDB
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1. Extraction
|
#### 1. Extraction
|
||||||
|
|
||||||
Le pipeline charge :
|
Le pipeline charge :
|
||||||
|
|
||||||
- `all_sites_combined.csv` avec Pandas ;
|
- `all_sites_combined.csv` avec Pandas ;
|
||||||
- `dataset_metadata.json` avec le module JSON de Python.
|
- `dataset_metadata.json` avec le module JSON de Python.
|
||||||
|
|
||||||
### 2. Validation
|
#### 2. Validation
|
||||||
|
|
||||||
Avant toute écriture en base, le pipeline contrôle notamment :
|
Avant toute écriture en base, le pipeline contrôle notamment :
|
||||||
|
|
||||||
@@ -136,7 +136,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.
|
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.
|
Un mode `--dry-run` permet d'exécuter les contrôles sans écrire de données dans PostgreSQL.
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ Il permet notamment de vérifier :
|
|||||||
- les valeurs NULL ;
|
- les valeurs NULL ;
|
||||||
- l'empreinte SHA-256.
|
- 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é.
|
Une empreinte SHA-256 est calculée à partir du fichier CSV afin d'identifier le dataset utilisé.
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ Empreinte SHA-256 du dataset validé :
|
|||||||
|
|
||||||
Cette empreinte participe à la traçabilité du dataset chargé.
|
Cette empreinte participe à la traçabilité du dataset chargé.
|
||||||
|
|
||||||
### 5. Transformation
|
#### 5. Transformation
|
||||||
|
|
||||||
Les timestamps sont normalisés avec la timezone :
|
Les timestamps sont normalisés avec la timezone :
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ imputed_values = NULL
|
|||||||
imputation_method = NULL
|
imputation_method = NULL
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Chargement
|
#### 6. Chargement
|
||||||
|
|
||||||
Le chargement est réalisé avec SQLAlchemy Async dans PostgreSQL/TimescaleDB.
|
Le chargement est réalisé avec SQLAlchemy Async dans PostgreSQL/TimescaleDB.
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ dataset_id = identifiant du dataset
|
|||||||
|
|
||||||
Cette représentation respecte les contraintes définies dans le schéma de la base.
|
Cette représentation respecte les contraintes définies dans le schéma de la base.
|
||||||
|
|
||||||
## Dataset validé
|
### Dataset validé
|
||||||
|
|
||||||
Le dataset traité contient :
|
Le dataset traité contient :
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ Valeurs manquantes identifiées :
|
|||||||
| `humidity_percent` | 3 423 |
|
| `humidity_percent` | 3 423 |
|
||||||
| `solar_irradiance_wm2` | 3 964 |
|
| `solar_irradiance_wm2` | 3 964 |
|
||||||
|
|
||||||
## Exécution historique en dry-run
|
### Exécution historique en dry-run
|
||||||
|
|
||||||
Depuis le dossier :
|
Depuis le dossier :
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ uv run python -m app.etl.historical_import `
|
|||||||
|
|
||||||
Aucune donnée n'est écrite dans la base pendant cette exécution.
|
Aucune donnée n'est écrite dans la base pendant cette exécution.
|
||||||
|
|
||||||
## Chargement historique réel
|
### Chargement historique réel
|
||||||
|
|
||||||
Depuis `apps/backend/` :
|
Depuis `apps/backend/` :
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ Chargement : 2000/122647
|
|||||||
Chargement : 122647/122647
|
Chargement : 122647/122647
|
||||||
```
|
```
|
||||||
|
|
||||||
## Résultats obtenus pour le dataset historique
|
### Résultats obtenus pour le dataset historique
|
||||||
|
|
||||||
Après le chargement initial, les contrôles en base ont confirmé :
|
Après le chargement initial, les contrôles en base ont confirmé :
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ Le premier import a créé :
|
|||||||
nouvelles lectures : 122647
|
nouvelles lectures : 122647
|
||||||
```
|
```
|
||||||
|
|
||||||
## Idempotence du dataset historique
|
### Idempotence du dataset historique
|
||||||
|
|
||||||
Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son 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 +299,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é.
|
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 du dataset historique
|
||||||
|
|
||||||
Depuis la racine du projet, vérifier le nombre d'enregistrements avec :
|
Depuis la racine du projet, vérifier le nombre d'enregistrements avec :
|
||||||
|
|
||||||
@@ -327,9 +327,9 @@ Résultat attendu pour le dataset historique :
|
|||||||
csv | 122647
|
csv | 122647
|
||||||
```
|
```
|
||||||
|
|
||||||
# Import depuis l'API Mock
|
## Import depuis l'API Mock
|
||||||
|
|
||||||
## Fonctionnement
|
### Fonctionnement
|
||||||
|
|
||||||
Le script d'import de l'API Mock se trouve dans :
|
Le script d'import de l'API Mock se trouve dans :
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ limit
|
|||||||
|
|
||||||
Le paramètre `limit` doit être compris entre 1 et 1000.
|
Le paramètre `limit` doit être compris entre 1 et 1000.
|
||||||
|
|
||||||
## Configuration de l'API Mock
|
### Configuration de l'API Mock
|
||||||
|
|
||||||
La connexion à l'API Mock est configurée avec les variables d'environnement suivantes :
|
La connexion à l'API Mock est configurée avec les variables d'environnement suivantes :
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ Les identifiants réels ne sont pas versionnés dans Git.
|
|||||||
|
|
||||||
Les fichiers `.env.example` indiquent uniquement les variables nécessaires à l'exécution.
|
Les fichiers `.env.example` indiquent uniquement les variables nécessaires à l'exécution.
|
||||||
|
|
||||||
## Transformation des mesures API
|
### Transformation des mesures API
|
||||||
|
|
||||||
Les mesures provenant de l'API Mock sont enregistrées dans `reading` avec :
|
Les mesures provenant de l'API Mock sont enregistrées dans `reading` avec :
|
||||||
|
|
||||||
@@ -422,7 +422,7 @@ raw_data
|
|||||||
|
|
||||||
afin de préserver la donnée reçue et faciliter la traçabilité.
|
afin de préserver la donnée reçue et faciliter la traçabilité.
|
||||||
|
|
||||||
## Qualité des données API
|
### Qualité des données API
|
||||||
|
|
||||||
Les valeurs `NULL` fournies par l'API sont conservées telles quelles.
|
Les valeurs `NULL` fournies par l'API sont conservées telles quelles.
|
||||||
|
|
||||||
@@ -444,6 +444,9 @@ degraded
|
|||||||
critical
|
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 :
|
Aucune imputation n'est réalisée pendant l'ingestion :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -453,7 +456,42 @@ 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.
|
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.
|
||||||
|
|
||||||
## Dry-run de l'API Mock
|
### 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.
|
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.
|
||||||
|
|
||||||
@@ -467,7 +505,7 @@ uv run python -m app.etl.mock_api_import `
|
|||||||
--dry-run
|
--dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Chargement réel depuis l'API Mock
|
### Chargement réel depuis l'API Mock
|
||||||
|
|
||||||
Depuis `apps/backend/` :
|
Depuis `apps/backend/` :
|
||||||
|
|
||||||
@@ -478,7 +516,7 @@ uv run python -m app.etl.mock_api_import `
|
|||||||
--limit 60
|
--limit 60
|
||||||
```
|
```
|
||||||
|
|
||||||
## Résultat validé pour l'API Mock
|
### Résultat validé pour l'API Mock
|
||||||
|
|
||||||
Le scénario de validation utilisé couvre la période :
|
Le scénario de validation utilisé couvre la période :
|
||||||
|
|
||||||
@@ -511,7 +549,7 @@ Les contrôles effectués directement dans PostgreSQL/TimescaleDB ont confirmé
|
|||||||
- la conservation de `null_reasons` ;
|
- la conservation de `null_reasons` ;
|
||||||
- la conservation de la donnée source dans `raw_data`.
|
- la conservation de la donnée source dans `raw_data`.
|
||||||
|
|
||||||
## Idempotence de l'import API Mock
|
### 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.
|
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.
|
||||||
|
|
||||||
@@ -519,7 +557,7 @@ L'idempotence repose sur la contrainte d'unicité de la table `reading` et sur l
|
|||||||
|
|
||||||
Un test d'intégration automatisé vérifie également ce comportement.
|
Un test d'intégration automatisé vérifie également ce comportement.
|
||||||
|
|
||||||
# Tests et qualité
|
## Tests et qualité
|
||||||
|
|
||||||
Les tests automatisés des pipelines ETL sont situés dans :
|
Les tests automatisés des pipelines ETL sont situés dans :
|
||||||
|
|
||||||
@@ -599,7 +637,7 @@ Lors de la validation de l'import API Mock :
|
|||||||
|
|
||||||
La suite backend complète a également été validée avec une couverture supérieure au seuil de 85 %.
|
La suite backend complète a également été validée avec une couverture supérieure au seuil de 85 %.
|
||||||
|
|
||||||
# Suite du pipeline Data
|
## Suite du pipeline Data
|
||||||
|
|
||||||
Deux sources de données sont maintenant prises en charge :
|
Deux sources de données sont maintenant prises en charge :
|
||||||
|
|
||||||
@@ -625,10 +663,16 @@ mock_api_import.py
|
|||||||
|
|
||||||
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
||||||
|
|
||||||
La prochaine étape consiste à orchestrer ces traitements avec Apache Airflow.
|
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline
|
||||||
|
ML (`ml_train`/`ml_score`, issue #115), la détection d'alertes et la génération des
|
||||||
|
recommandations (`alertes`, issue #116), ainsi que l'import historique
|
||||||
|
(`historical_import`, issue #119).
|
||||||
|
|
||||||
Airflow permettra de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs.
|
Le DAG `historical_import` est déclenché manuellement. Il exécute
|
||||||
|
`app.etl.historical_import` avec les fichiers montés en lecture seule depuis `data/raw` vers
|
||||||
|
`/opt/data/raw`. L'orchestration de l'import API Mock et la réconciliation globale des deux
|
||||||
|
sources restent couvertes par l'issue #15.
|
||||||
|
|
||||||
Airflow ne remplacera pas la logique ETL Python existante. Les scripts actuels resteront responsables de l'extraction, de la validation, de la transformation et du chargement.
|
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` et `historical_import.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.
|
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""DAG d'import du dataset historique EnerVision (issue #119).
|
||||||
|
|
||||||
|
Orchestre le pipeline existant `app.etl.historical_import` sans dupliquer sa logique ETL.
|
||||||
|
Le dataset historique sert à initialiser l'environnement : le DAG reste donc manuel.
|
||||||
|
|
||||||
|
Le backend est exécuté dans l'environnement `/opt/backend` embarqué dans l'image Airflow,
|
||||||
|
sur le même patron que le DAG `alertes` (ADR 0008).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from airflow.models.dag import DAG
|
||||||
|
from airflow.operators.bash import BashOperator
|
||||||
|
|
||||||
|
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
||||||
|
|
||||||
|
CSV_PATH = "/opt/data/raw/all_sites_combined.csv"
|
||||||
|
METADATA_PATH = "/opt/data/raw/dataset_metadata.json"
|
||||||
|
SOURCE_TIMEZONE = "UTC"
|
||||||
|
BATCH_SIZE = 1000
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
dag_id="historical_import",
|
||||||
|
description="Importe le dataset historique CSV/JSON dans dataset, site et reading.",
|
||||||
|
schedule=None,
|
||||||
|
start_date=datetime(2026, 1, 1),
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=["etl", "historical"],
|
||||||
|
) as dag:
|
||||||
|
BashOperator(
|
||||||
|
task_id="import_historical",
|
||||||
|
bash_command=(
|
||||||
|
f"{COMMANDE_BACKEND} app.etl.historical_import "
|
||||||
|
f"--csv {CSV_PATH} "
|
||||||
|
f"--metadata {METADATA_PATH} "
|
||||||
|
"--source-timezone UTC "
|
||||||
|
"--batch-size 1000"
|
||||||
|
),
|
||||||
|
retries=1,
|
||||||
|
retry_delay=timedelta(minutes=2),
|
||||||
|
execution_timeout=timedelta(minutes=30),
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""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),
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""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),
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[project]
|
||||||
|
name = "enervision-airflow"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "DAGs d'orchestration EnerVision (Airflow)"
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
dependencies = [
|
||||||
|
"apache-airflow==2.10.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.16.7",
|
||||||
|
"pytest>=9.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
src = ["dags", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "W", "F", "I", "N", "UP", "B", "SIM", "RUF"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Isole Airflow d'un `~/airflow` reel : `AIRFLOW_HOME` doit etre pose avant le premier `import
|
||||||
|
airflow`, donc ici plutot que dans une fixture (les fixtures s'executent trop tard, apres que les
|
||||||
|
modules de test aient deja importe `airflow`)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_AIRFLOW_HOME = Path(__file__).resolve().parent / ".airflow_home"
|
||||||
|
_AIRFLOW_HOME.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
os.environ.setdefault("AIRFLOW_HOME", str(_AIRFLOW_HOME))
|
||||||
|
os.environ.setdefault("AIRFLOW__CORE__LOAD_EXAMPLES", "False")
|
||||||
|
os.environ.setdefault("AIRFLOW__CORE__UNIT_TEST_MODE", "True")
|
||||||
|
os.environ.setdefault(
|
||||||
|
"AIRFLOW__DATABASE__SQL_ALCHEMY_CONN", f"sqlite:///{_AIRFLOW_HOME / 'airflow.db'}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution
|
||||||
|
reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from airflow.models.baseoperator import BaseOperator
|
||||||
|
from airflow.models.dagbag import DagBag
|
||||||
|
|
||||||
|
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
|
||||||
|
|
||||||
|
DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import"]
|
||||||
|
TACHES = [
|
||||||
|
("ml_train", "train"),
|
||||||
|
("ml_score", "score"),
|
||||||
|
("alertes", "detection"),
|
||||||
|
("alertes", "recommandations"),
|
||||||
|
("historical_import", "import_historical"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def dagbag() -> DagBag:
|
||||||
|
return DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None:
|
||||||
|
assert dagbag.import_errors == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None:
|
||||||
|
assert set(dagbag.dag_ids) == set(DAG_IDS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_train_has_no_schedule(dagbag: DagBag) -> None:
|
||||||
|
assert dagbag.dags["ml_train"].timetable.summary == "None"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_score_runs_every_hour(dagbag: DagBag) -> None:
|
||||||
|
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend.
|
||||||
|
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *"
|
||||||
|
|
||||||
|
|
||||||
|
def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None:
|
||||||
|
# Le decalage n'est pas cosmetique : la regle `anomaly` compare une lecture a la `prediction`
|
||||||
|
# du meme instant, que `ml_score` ecrit a l'heure pile.
|
||||||
|
assert dagbag.dags["alertes"].timetable.summary == "15 * * * *"
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_import_has_no_schedule(dagbag: DagBag) -> None:
|
||||||
|
assert dagbag.dags["historical_import"].timetable.summary == "None"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
|
||||||
|
tache = dagbag.dags["ml_train"].get_task("train")
|
||||||
|
assert "enervision_ml.train" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_score_task_calls_the_scoring_module(dagbag: DagBag) -> None:
|
||||||
|
tache = dagbag.dags["ml_score"].get_task("score")
|
||||||
|
assert "enervision_ml.score" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_alertes_detection_task_calls_the_backend_detection(dagbag: DagBag) -> None:
|
||||||
|
tache = dagbag.dags["alertes"].get_task("detection")
|
||||||
|
assert "app.detection.internal_alerts" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_alertes_recommendation_task_calls_the_backend_cli(dagbag: DagBag) -> None:
|
||||||
|
tache = dagbag.dags["alertes"].get_task("recommandations")
|
||||||
|
assert "app.cli generate-recommendations" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_import_calls_the_existing_backend_module(dagbag: DagBag) -> None:
|
||||||
|
tache = dagbag.dags["historical_import"].get_task("import_historical")
|
||||||
|
assert "app.etl.historical_import" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> None:
|
||||||
|
commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command
|
||||||
|
|
||||||
|
assert "--csv /opt/data/raw/all_sites_combined.csv" in commande
|
||||||
|
assert "--metadata /opt/data/raw/dataset_metadata.json" in commande
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
||||||
|
def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None:
|
||||||
|
# Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008).
|
||||||
|
assert "/opt/backend" in dagbag.dags["alertes"].get_task(task_id).bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_import_runs_in_the_backend_environment(dagbag: DagBag) -> None:
|
||||||
|
commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command
|
||||||
|
assert "/opt/backend" in commande
|
||||||
|
|
||||||
|
|
||||||
|
def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None:
|
||||||
|
# `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire
|
||||||
|
# tant que la detection n'a pas ecrit.
|
||||||
|
assert dagbag.dags["alertes"].get_task("detection").downstream_task_ids == {"recommandations"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> None:
|
||||||
|
entrainement = dagbag.dags["ml_train"].get_task("train").bash_command
|
||||||
|
scoring = dagbag.dags["ml_score"].get_task("score").bash_command
|
||||||
|
chemin_modele = "/opt/ml/state/models/lightgbm-consumption.txt"
|
||||||
|
|
||||||
|
assert chemin_modele in entrainement
|
||||||
|
assert chemin_modele in scoring
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("dag_id", DAG_IDS)
|
||||||
|
def test_no_two_runs_of_a_dag_overlap(dagbag: DagBag, dag_id: str) -> None:
|
||||||
|
# Deux entrainements ecriraient le meme fichier modele, deux scorings inseriraient en meme
|
||||||
|
# temps dans `prediction`, deux detections analyseraient la meme fenetre.
|
||||||
|
assert dagbag.dags[dag_id].max_active_runs == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
|
||||||
|
def test_every_task_has_an_execution_timeout(dagbag: DagBag, dag_id: str, task_id: str) -> None:
|
||||||
|
# Sans plafond, une connexion pendue immobilise un slot du scheduler indefiniment.
|
||||||
|
assert dagbag.dags[dag_id].get_task(task_id).execution_timeout is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_score_execution_timeout_stays_below_its_hourly_step(dagbag: DagBag) -> None:
|
||||||
|
timeout = dagbag.dags["ml_score"].get_task("score").execution_timeout
|
||||||
|
assert timeout is not None
|
||||||
|
assert timeout < timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
def duree_au_pire(tache: BaseOperator) -> timedelta:
|
||||||
|
# `execution_timeout` plafonne une tentative, pas la tache : deux reprises occupent trois
|
||||||
|
# plafonds et deux delais d'attente.
|
||||||
|
assert tache.execution_timeout is not None
|
||||||
|
return (tache.retries + 1) * tache.execution_timeout + tache.retries * tache.retry_delay
|
||||||
|
|
||||||
|
|
||||||
|
def test_alertes_worst_case_stays_below_its_hourly_step(dagbag: DagBag) -> None:
|
||||||
|
# Les deux taches s'enchainent : c'est leur somme, reprises comprises, qui doit tenir dans le
|
||||||
|
# pas horaire, sinon `max_active_runs=1` fait attendre l'execution suivante.
|
||||||
|
taches = [
|
||||||
|
dagbag.dags["alertes"].get_task(task_id) for task_id in ("detection", "recommandations")
|
||||||
|
]
|
||||||
|
assert sum((duree_au_pire(tache) for tache in taches), timedelta()) < timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_score_retries_after_a_transient_failure(dagbag: DagBag) -> None:
|
||||||
|
assert dagbag.dags["ml_score"].get_task("score").retries >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
||||||
|
def test_alertes_retries_after_a_transient_failure(dagbag: DagBag, task_id: str) -> None:
|
||||||
|
# Les deux commandes sont idempotentes en base, une reprise ne duplique rien.
|
||||||
|
assert dagbag.dags["alertes"].get_task(task_id).retries >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) -> None:
|
||||||
|
assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
|
||||||
|
def test_tasks_never_resync_the_baked_environment(
|
||||||
|
dagbag: DagBag, dag_id: str, task_id: str
|
||||||
|
) -> None:
|
||||||
|
# Sans `--no-sync`, `uv run` reconstruit le projet a chaque execution.
|
||||||
|
assert "--no-sync" in dagbag.dags[dag_id].get_task(task_id).bash_command
|
||||||
Generated
+1970
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
# Reverse proxy
|
||||||
|
|
||||||
|
Terminaison TLS et routage de la stack déployée. Seul composant publié sur le réseau : il
|
||||||
|
écoute en 80 et 443, et rien d'autre ne sort du réseau Compose.
|
||||||
|
|
||||||
|
- `nginx.conf` : bloc `http`, journalisation, compression, zones de limitation de débit.
|
||||||
|
- `conf.d/enervision.conf` : redirection 80 vers 443, terminaison TLS, en-têtes de sécurité,
|
||||||
|
routage.
|
||||||
|
- `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git.
|
||||||
|
- `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`.
|
||||||
|
|
||||||
|
Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la
|
||||||
|
configuration est montée en volume par `docker-compose.prod.yml`.
|
||||||
|
|
||||||
|
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
|
||||||
|
ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
|
||||||
|
|
||||||
|
## Routage
|
||||||
|
|
||||||
|
| Chemin | Destination | Remarque |
|
||||||
|
|---|---|---|
|
||||||
|
| `/.well-known/acme-challenge/` | `/var/www/certbot` sur le port 80 | Seul chemin non redirigé vers HTTPS |
|
||||||
|
| `/api/v1/auth/` + `login`, `password`, `forgot-password`, `reset-password` | `backend:8000` | Zone resserrée, 30 requêtes par minute |
|
||||||
|
| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel, 20 requêtes par seconde |
|
||||||
|
| `/` | `frontend:3000` | Le SPA, qui renvoie `index.html` sur les routes inconnues |
|
||||||
|
|
||||||
|
La zone resserrée ne couvre que les routes qui vérifient un secret. `/auth/me` et `/auth/refresh`
|
||||||
|
partent à chaque chargement de page et restent dans la zone générale : derrière un NAT, où une
|
||||||
|
seule adresse porte tous les postes, les y soumettre aurait produit des 429 en usage normal.
|
||||||
|
|
||||||
|
L'interface Airflow, celle de Mailpit et la base ne passent pas par le proxy : l'overlay les
|
||||||
|
ramène sur `127.0.0.1`, donc joignables par tunnel SSH et pas autrement. Les publier derrière le
|
||||||
|
proxy demanderait une authentification propre, qui n'est pas la leur.
|
||||||
|
|
||||||
|
`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**,
|
||||||
|
pas sous `/api`. Ils tombent donc dans `location /`, donc sur le SPA : ils ne sont pas joignables
|
||||||
|
depuis l'extérieur, sans qu'aucune règle de blocage ait à être écrite. Y toucher, c'est les
|
||||||
|
exposer.
|
||||||
|
|
||||||
|
## Certificat : deux modes, un seul emplacement
|
||||||
|
|
||||||
|
nginx lit toujours `tls/fullchain.pem` et `tls/privkey.pem`. Seule leur fabrication change, la
|
||||||
|
configuration n'a jamais à bouger.
|
||||||
|
|
||||||
|
### Démonstration, certificat auto-signé
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make tls-selfsigned PUBLIC_HOST=enervision.local
|
||||||
|
make stack-up
|
||||||
|
```
|
||||||
|
|
||||||
|
Le navigateur avertira d'un émetteur inconnu : c'est attendu, et c'est le seul mode exploitable
|
||||||
|
tant que la machine cible n'a pas de nom de domaine public.
|
||||||
|
|
||||||
|
### Let's Encrypt
|
||||||
|
|
||||||
|
Le défi HTTP-01 exige un nom de domaine **résolvable publiquement** et le port 80 joignable
|
||||||
|
depuis Internet. La cible documentée aujourd'hui (`ssh_host = "10.0.0.10"`, serveur de l'école)
|
||||||
|
ne remplit ni l'une ni l'autre condition : le chemin ci-dessous est livré et documenté, il n'a
|
||||||
|
pas été exercé.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make stack-up # nginx doit tourner pour servir le défi
|
||||||
|
make tls-acme PUBLIC_HOST=enervision.fr ACME_EMAIL=ops@enervision.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
Renouvellement, à passer en tâche planifiée sur la machine :
|
||||||
|
|
||||||
|
```cron
|
||||||
|
17 3 * * * cd /srv/enervision && make tls-renew >> /var/log/enervision-tls.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour un domaine sans port 80 entrant, le défi DNS-01 est l'alternative : elle demande un
|
||||||
|
greffon certbot propre au fournisseur DNS et un jeton d'API, hors périmètre à ce jour.
|
||||||
|
|
||||||
|
## Vérifier la configuration sans démarrer la stack
|
||||||
|
|
||||||
|
`nginx -t` charge les certificats : `tls/` doit être rempli, par `make tls-selfsigned` au besoin.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \
|
||||||
|
-v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \
|
||||||
|
-v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \
|
||||||
|
nginx:1.28-alpine nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image.
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Contrainte : certbot écrit dans /etc/letsencrypt/live/<domaine>/, nginx lit /etc/nginx/tls/.
|
||||||
|
# Ce hook recopie le résultat à l'emplacement unique que la configuration nginx connaît, ce
|
||||||
|
# qui rend le mode auto-signé et le mode ACME interchangeables sans toucher à un vhost.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cp -L "$RENEWED_LINEAGE/fullchain.pem" /tls/fullchain.pem
|
||||||
|
cp -L "$RENEWED_LINEAGE/privkey.pem" /tls/privkey.pem
|
||||||
|
chmod 644 /tls/fullchain.pem
|
||||||
|
chmod 600 /tls/privkey.pem
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Piège : `X-Forwarded-For` se construit avec `$proxy_add_x_forwarded_for`, qui ajoute l'IP
|
||||||
|
# réelle en fin de chaîne. `get_client_ip()` (apps/backend/app/api/deps.py) ne lit que le
|
||||||
|
# dernier élément : toute autre forme rend la limitation de débit par IP globale, donc le
|
||||||
|
# déni de service auto-infligé que ce code cherche précisément à éviter.
|
||||||
|
# Piège : un nom d'hôte littéral dans `proxy_pass` fige l'IP du conteneur au démarrage de
|
||||||
|
# nginx, et recréer `backend` seul donnerait des 502 jusqu'au rechargement du proxy. D'où la
|
||||||
|
# variable et le résolveur interne de Docker : la résolution redevient dynamique.
|
||||||
|
# Pourquoi : la redirection 80 vers 443 conserve `$host` plutôt qu'un nom canonique, faute de
|
||||||
|
# quoi l'accès par IP cesserait de fonctionner sur la cible. Risque acté dans l'ADR 0007.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/tls/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/tls/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
# L'application refuse délibérément de poser ces deux en-têtes, verrouillé par
|
||||||
|
# tests/api/test_hardening.py. Ils appartiennent au terminateur TLS, c'est-à-dire ici.
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
|
||||||
|
# Piège : la zone `auth` ne couvre que les routes qui vérifient un secret. Derrière le NAT de
|
||||||
|
# l'école, `/auth/me` et `/auth/refresh` y produiraient des 429 à chaque chargement de page.
|
||||||
|
location ~ ^/api/v1/auth/(login|password|forgot-password|reset-password)$ {
|
||||||
|
limit_req zone=auth burst=20 nodelay;
|
||||||
|
set $cible_api http://backend:8000;
|
||||||
|
proxy_pass $cible_api$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=40 nodelay;
|
||||||
|
set $cible_api http://backend:8000;
|
||||||
|
proxy_pass $cible_api$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
set $cible_web http://frontend:3000;
|
||||||
|
proxy_pass $cible_web$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Contrainte : les directives `limit_req_zone` ne sont valides que dans le bloc `http`.
|
||||||
|
# Les `location` de conf.d/enervision.conf s'y réfèrent par nom, `api` et `auth`.
|
||||||
|
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
log_format enervision '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent $request_time '
|
||||||
|
'"$http_referer" "$http_user_agent"';
|
||||||
|
access_log /var/log/nginx/access.log enervision;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
client_max_body_size 2m;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types application/javascript application/json application/xml
|
||||||
|
image/svg+xml text/css text/plain;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=auth:10m rate=30r/m;
|
||||||
|
limit_req_status 429;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Pipeline d'entrainement du modele de prevision de consommation energetique. Contexte complet :
|
Pipeline d'entrainement du modele de prevision de consommation energetique. Contexte complet :
|
||||||
[ADR 0005](../docs/adr/0005-modele-prediction-lightgbm.md) (choix du modele) et
|
[ADR 0005](../docs/adr/0005-modele-prediction-lightgbm.md) (choix du modele) et
|
||||||
[ML-START.md](../ML-START.md) (mecanisme d'acces aux donnees).
|
[ML-START.md](../docs/ML-START.md) (mecanisme d'acces aux donnees).
|
||||||
|
|
||||||
| Element | Choix |
|
| Element | Choix |
|
||||||
|--------------|-----------------------------------------------|
|
|--------------|-----------------------------------------------|
|
||||||
@@ -103,7 +103,7 @@ prevision (utile plus tard pour comparer prevision et realise, surveillance de d
|
|||||||
uv run ruff check . # lint
|
uv run ruff check . # lint
|
||||||
uv run ruff format . # format
|
uv run ruff format . # format
|
||||||
uv run mypy enervision_ml tests # typage strict
|
uv run mypy enervision_ml tests # typage strict
|
||||||
uv run pytest # tests
|
uv run pytest # tests + couverture (ml/coverage.xml avec --cov-report=xml, lu par Sonar)
|
||||||
```
|
```
|
||||||
|
|
||||||
Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`,
|
Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`,
|
||||||
|
|||||||
+11
-1
@@ -17,6 +17,7 @@ dev = [
|
|||||||
"ruff>=0.16.7",
|
"ruff>=0.16.7",
|
||||||
"mypy>=2.3.1",
|
"mypy>=2.3.1",
|
||||||
"pytest>=9.1.1",
|
"pytest>=9.1.1",
|
||||||
|
"pytest-cov>=7.1.0",
|
||||||
"pandas-stubs>=3.0.5.260914",
|
"pandas-stubs>=3.0.5.260914",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -75,5 +76,14 @@ ignore_missing_imports = true
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "-q --strict-markers -m 'not integration'"
|
addopts = "-q --strict-markers -m 'not integration' --cov=enervision_ml --cov-report=term-missing"
|
||||||
markers = ["integration: requiert une base PostgreSQL joignable"]
|
markers = ["integration: requiert une base PostgreSQL joignable"]
|
||||||
|
|
||||||
|
# Rapport lu par SonarCloud (`ml/coverage.xml`, cf. sonar-project.properties), meme mecanisme que
|
||||||
|
# apps/backend. Pas de seuil ici : celui de la quality gate porte sur le code nouveau.
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["enervision_ml"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
show_missing = true
|
||||||
|
|||||||
Generated
+55
@@ -388,6 +388,45 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.16.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "50.0.1"
|
version = "50.0.1"
|
||||||
@@ -494,6 +533,7 @@ dev = [
|
|||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "pandas-stubs" },
|
{ name = "pandas-stubs" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -512,6 +552,7 @@ dev = [
|
|||||||
{ name = "mypy", specifier = ">=2.3.1" },
|
{ name = "mypy", specifier = ">=2.3.1" },
|
||||||
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
|
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
|
||||||
{ name = "pytest", specifier = ">=9.1.1" },
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
{ name = "ruff", specifier = ">=0.16.7" },
|
{ name = "ruff", specifier = ">=0.16.7" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1591,6 +1632,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
|
|||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Contrainte : nginx lit toujours infra/proxy/tls/{fullchain,privkey}.pem, quel que soit le
|
||||||
|
# mode d'obtention. Ce script remplit ces deux fichiers pour la démonstration, certbot les
|
||||||
|
# remplit par acme-deploy-hook.sh. La configuration nginx ne connaît pas la différence.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RACINE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
DESTINATION="$RACINE/infra/proxy/tls"
|
||||||
|
HOTE="${PUBLIC_HOST:-enervision.local}"
|
||||||
|
ADRESSE="${PUBLIC_IP:-}"
|
||||||
|
JOURS="${TLS_DAYS:-365}"
|
||||||
|
ECRASER=0
|
||||||
|
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$argument" in
|
||||||
|
--force) ECRASER=1 ;;
|
||||||
|
*)
|
||||||
|
echo "Usage : PUBLIC_HOST=exemple.local [PUBLIC_IP=10.0.0.10] $0 [--force]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -f "$DESTINATION/fullchain.pem" && $ECRASER -eq 0 ]]; then
|
||||||
|
echo "Un certificat existe déjà dans $DESTINATION." >&2
|
||||||
|
echo "Relancer avec --force pour l'écraser." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DESTINATION"
|
||||||
|
|
||||||
|
NOMS="DNS:$HOTE,DNS:localhost"
|
||||||
|
if [[ -n "$ADRESSE" ]]; then
|
||||||
|
NOMS="$NOMS,IP:$ADRESSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
openssl req -x509 -nodes -newkey rsa:2048 -sha256 -days "$JOURS" \
|
||||||
|
-subj "/CN=$HOTE" \
|
||||||
|
-addext "subjectAltName=$NOMS" \
|
||||||
|
-keyout "$DESTINATION/privkey.pem" \
|
||||||
|
-out "$DESTINATION/fullchain.pem" 2>/dev/null
|
||||||
|
|
||||||
|
chmod 600 "$DESTINATION/privkey.pem"
|
||||||
|
chmod 644 "$DESTINATION/fullchain.pem"
|
||||||
|
|
||||||
|
echo "Certificat auto-signé écrit dans $DESTINATION."
|
||||||
|
echo " Noms couverts : $NOMS"
|
||||||
|
echo " Validité : $JOURS jours"
|
||||||
|
echo "Le navigateur avertira d'un émetteur inconnu, c'est attendu hors Let's Encrypt."
|
||||||
@@ -3,15 +3,17 @@ sonar.organization=groupe3-ener-vision
|
|||||||
sonar.sourceEncoding=UTF-8
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
# Dossier contenant le code source
|
# Dossier contenant le code source
|
||||||
sonar.sources=apps/frontend/src,apps/backend
|
sonar.sources=apps/frontend/src,apps/backend,ml,etl/airflow
|
||||||
# Dossier contenant les tests
|
# Dossier contenant les tests
|
||||||
sonar.tests=apps/frontend/src,apps/backend/tests
|
sonar.tests=apps/frontend/src,apps/backend/tests,ml/tests,etl/airflow/tests
|
||||||
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
||||||
|
|
||||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
# Liste des fichiers et dossiers à exclure de l'analyse
|
||||||
sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
|
sonar.exclusions=.pytest_cache,.venv,.airflow_home,alembic,tests,ml/data/**,ml/models/**,ml/mlruns/**,ml/mlartifacts/**,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
|
||||||
|
|
||||||
# Chemin vers le rapport de couverture de code
|
# Chemin vers le rapport de couverture de code
|
||||||
# Fichier généré par Pytest
|
# Fichier généré par Pytest
|
||||||
sonar.python.coverage.reportPaths=apps/backend/coverage.xml
|
sonar.python.coverage.reportPaths=apps/backend/coverage.xml,ml/coverage.xml
|
||||||
|
# Les DAGs n'ont pas de couverture mesurable : leurs tests ne font que les charger (DagBag)
|
||||||
|
sonar.coverage.exclusions=etl/airflow/**
|
||||||
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
||||||
|
|||||||
Reference in New Issue
Block a user