Compare commits
162
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6301ffedd7 | ||
|
|
59f050ec5e | ||
|
|
6e9c830557 | ||
|
|
5a3c526856 | ||
|
|
314e3b72c0 | ||
|
|
e118c008bf | ||
|
|
d86224a0f7 | ||
|
|
f21a843fc2 | ||
|
|
6b3908d321 | ||
|
|
da5d2d838a | ||
|
|
b16861e211 | ||
|
|
57b9735804 | ||
|
|
c007ea01bd | ||
|
|
5472b19504 | ||
|
|
44163bfb98 | ||
|
|
ea8f9d0a3a | ||
|
|
f58fc4ba81 | ||
|
|
cb961ec2c5 | ||
|
|
68239371f6 | ||
|
|
9bf2f27127 | ||
|
|
568060a903 | ||
|
|
2c7ee2c064 | ||
|
|
aa4af62290 | ||
|
|
5b5c97532d | ||
|
|
e220f8f0c6 | ||
|
|
fb4235df3a | ||
|
|
357cf996ed | ||
|
|
4fb8b5a784 | ||
|
|
c1f9dec5fe | ||
|
|
d9103ee4ed | ||
|
|
fca36649f9 | ||
|
|
0db69fd023 | ||
|
|
be6be42947 | ||
|
|
3ddeb24207 | ||
|
|
8760ebc701 | ||
|
|
a013dfa87f | ||
|
|
b5993f994e | ||
|
|
091ee344d1 | ||
|
|
f81959c1a9 | ||
|
|
5c361c6952 | ||
|
|
a27e0c6535 | ||
|
|
7a97a4d1ac | ||
|
|
20abcfee33 | ||
|
|
3d6d2eb760 | ||
|
|
154deca511 | ||
|
|
a577356198 | ||
|
|
19ff152445 | ||
|
|
bf3584182f | ||
|
|
28e8260216 | ||
|
|
e90e563a1c | ||
|
|
d7ca9fd8b0 | ||
|
|
67204b5eb6 | ||
|
|
985466e9cc | ||
|
|
c347212c93 | ||
|
|
c67c7802c1 | ||
|
|
e824423889 | ||
|
|
436147c8cb | ||
|
|
8fb7ab8680 | ||
|
|
65baf3fddb | ||
|
|
19f4ddc62f | ||
|
|
282472bae9 | ||
|
|
8d80cdc79d | ||
|
|
b61287c8d2 | ||
|
|
a4be46c6e6 | ||
|
|
66a65991b5 | ||
|
|
095cb4559b | ||
|
|
d48b0eef4d | ||
|
|
c8e8a44390 | ||
|
|
401d5a3174 | ||
|
|
6a70443d52 | ||
|
|
ebf6476fb6 | ||
|
|
d2ca2644d2 | ||
|
|
6d741e45fb | ||
|
|
1bec2c1376 | ||
|
|
00fab49d80 | ||
|
|
91a1bfda33 | ||
|
|
de88c4f156 | ||
|
|
67dcf506e9 | ||
|
|
8386e05f31 | ||
|
|
fe100653d0 | ||
|
|
bccf7ed774 | ||
|
|
1502eea036 | ||
|
|
bf2b66e4ad | ||
|
|
8ea388ce9a | ||
|
|
9cd4f0de1c | ||
|
|
5cc99178c2 | ||
|
|
b4da0bbefc | ||
|
|
25e0bb4ce1 | ||
|
|
b41a16364b | ||
|
|
ce3d1d1992 | ||
|
|
33aeea835b | ||
|
|
cb23895026 | ||
|
|
9312d3b60f | ||
|
|
2c86e00629 | ||
|
|
cf50d3d36c | ||
|
|
7e504bee72 | ||
|
|
32155cbfc5 | ||
|
|
cb4df846cb | ||
|
|
d16de84559 | ||
|
|
ae4082c584 | ||
|
|
f18d4f9ef9 | ||
|
|
308769b325 | ||
|
|
90639b5618 | ||
|
|
e147ea69bc | ||
|
|
f0ff953a5e | ||
|
|
7fd8d1ce30 | ||
|
|
6785d06a9a | ||
|
|
c2bd1317ed | ||
|
|
d276070ee2 | ||
|
|
e10ab44dc7 | ||
|
|
32dd0587e9 | ||
|
|
4afcfb3ced | ||
|
|
83caa9006e | ||
|
|
91752a2b0b | ||
|
|
79b157d37e | ||
|
|
851e0279f6 | ||
|
|
7f025f72ac | ||
|
|
1cd206adc8 | ||
|
|
3c378c177f | ||
|
|
2adfdf0eb0 | ||
|
|
2a7e1c25fc | ||
|
|
5d955ca991 | ||
|
|
44f3416ffe | ||
|
|
342128ccff | ||
|
|
62d81e901d | ||
|
|
31886944ab | ||
|
|
63cbeafe3b | ||
|
|
f8d08c8686 | ||
|
|
be44b97d7a | ||
|
|
306c5a52e5 | ||
|
|
801379f956 | ||
|
|
2686880185 | ||
|
|
ae58a896d9 | ||
|
|
5a29faaa16 | ||
|
|
bc75528616 | ||
|
|
268496a8c4 | ||
|
|
d7457e9fd9 | ||
|
|
cefb07067d | ||
|
|
3cd9a6b272 | ||
|
|
26f834485c | ||
|
|
777cd0ac64 | ||
|
|
c528ed239b | ||
|
|
a88e51c92a | ||
|
|
f3ea2785b3 | ||
|
|
3cc6c67e0d | ||
|
|
5545c166fd | ||
|
|
f0ad8e9990 | ||
|
|
3c01ab3ecc | ||
|
|
93afc6031c | ||
|
|
0c487fa7be | ||
|
|
b3efb98208 | ||
|
|
2d7b4bd74d | ||
|
|
f9c2a4610c | ||
|
|
b5fa7b0010 | ||
|
|
99db807e3c | ||
|
|
7f710c9084 | ||
|
|
47c4fd445b | ||
|
|
667592033b | ||
|
|
6785c70f6f | ||
|
|
692ec436a5 | ||
|
|
a3d32a6fb1 | ||
|
|
c3fd9327ea |
+39
-3
@@ -17,6 +17,11 @@ 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
|
||||||
@@ -24,17 +29,48 @@ 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
|
# Airflow (api-server + scheduler + dag-processor, 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.
|
# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus.
|
||||||
AIRFLOW_PORT=8080
|
AIRFLOW_PORT=8080
|
||||||
# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre :
|
# 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())"
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
AIRFLOW_FERNET_KEY=change_me
|
AIRFLOW_FERNET_KEY=change_me
|
||||||
# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la
|
# Clé de session de l'api-server Airflow, distincte de la précédente. Générer la
|
||||||
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
|
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
AIRFLOW_WEBSERVER_SECRET_KEY=change_me
|
AIRFLOW_API_SECRET_KEY=change_me
|
||||||
|
# Secret des jetons JWT entre scheduler, tâches et api-server (conteneurs distincts, le secret
|
||||||
|
# doit être partagé). Même générateur que ci-dessus.
|
||||||
|
AIRFLOW_JWT_SECRET=change_me
|
||||||
AIRFLOW_ADMIN_USERNAME=admin
|
AIRFLOW_ADMIN_USERNAME=admin
|
||||||
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
|
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
|
||||||
# comptes `app_user` d'EnerVision.
|
# comptes `app_user` d'EnerVision.
|
||||||
AIRFLOW_ADMIN_PASSWORD=change_me
|
AIRFLOW_ADMIN_PASSWORD=change_me
|
||||||
AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
|
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=
|
||||||
|
|
||||||
|
# Deux environnements sur la même machine (ADR 0009) : un dossier, un `.env` et un projet Compose
|
||||||
|
# chacun. Le nom de projet préfixe volumes, réseau et conteneurs et l'emporte sur `name:`.
|
||||||
|
# Vide sur un poste de développement : le projet reste `enervision`.
|
||||||
|
COMPOSE_PROJECT_NAME=
|
||||||
|
# Origine publique, avec le port si le proxy HTTPS n'écoute pas 443. Vide : https://PUBLIC_HOST.
|
||||||
|
# Recette : PUBLIC_HOST=rec.enervision.local et PUBLIC_ORIGIN=https://rec.enervision.local:8443.
|
||||||
|
PUBLIC_ORIGIN=
|
||||||
|
# Ports publiés par le proxy. Vides : 80 et 443. Recette : PROXY_HTTPS_PORT=8443 et
|
||||||
|
# PROXY_HTTP_PORT=127.0.0.1:8081, la redirection vers 443 n'ayant pas à être joignable de
|
||||||
|
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
|
||||||
|
PROXY_HTTP_PORT=
|
||||||
|
PROXY_HTTPS_PORT=
|
||||||
|
# Réglages mémoire de la stack déployée. Sans eux, timescaledb-tune réserve 25 % de la RAM de la
|
||||||
|
# machine à chaque base au premier démarrage. L'api-server Airflow 3 n'a rien à régler ici : son
|
||||||
|
# nombre de workers vaut 1 par défaut, contre 4 pour le webserver d'Airflow 2.
|
||||||
|
TS_TUNE_MEMORY=2GB
|
||||||
|
TS_TUNE_NUM_CPUS=2
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ updates:
|
|||||||
frontend-dependencies:
|
frontend-dependencies:
|
||||||
patterns:
|
patterns:
|
||||||
- "*"
|
- "*"
|
||||||
|
ignore:
|
||||||
|
# Pourquoi : @angular/build épingle typescript (>=6.0 <6.1) et vitest (^4). Une majeure
|
||||||
|
# de l'un ou l'autre casse `npm ci` tant qu'Angular ne suit pas.
|
||||||
|
- dependency-name: "typescript"
|
||||||
|
update-types: ["version-update:semver-major"]
|
||||||
|
- dependency-name: "vitest"
|
||||||
|
update-types: ["version-update:semver-major"]
|
||||||
|
- dependency-name: "@vitest/coverage-v8"
|
||||||
|
update-types: ["version-update:semver-major"]
|
||||||
|
|
||||||
# Backend — uv (lit pyproject.toml / uv.lock)
|
# Backend — uv (lit pyproject.toml / uv.lock)
|
||||||
- package-ecosystem: "uv"
|
- package-ecosystem: "uv"
|
||||||
@@ -38,3 +47,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"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
name: Airflow
|
name: Airflow
|
||||||
|
|
||||||
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
|
# 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
|
# (contrairement à backend.yml et ml.yml) : celui de l'image apache/airflow retenue, et les tests
|
||||||
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
|
# doivent tourner sur le même interpréteur qu'elle. 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/. Une modification de ml/
|
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
|
||||||
# peut donc casser sa construction, d'où ces chemins dans les déclencheurs.
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -14,6 +16,9 @@ on:
|
|||||||
- "ml/pyproject.toml"
|
- "ml/pyproject.toml"
|
||||||
- "ml/uv.lock"
|
- "ml/uv.lock"
|
||||||
- "ml/enervision_ml/**"
|
- "ml/enervision_ml/**"
|
||||||
|
- "apps/backend/pyproject.toml"
|
||||||
|
- "apps/backend/uv.lock"
|
||||||
|
- "apps/backend/app/**"
|
||||||
- ".github/workflows/airflow.yml"
|
- ".github/workflows/airflow.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
@@ -21,6 +26,9 @@ on:
|
|||||||
- "ml/pyproject.toml"
|
- "ml/pyproject.toml"
|
||||||
- "ml/uv.lock"
|
- "ml/uv.lock"
|
||||||
- "ml/enervision_ml/**"
|
- "ml/enervision_ml/**"
|
||||||
|
- "apps/backend/pyproject.toml"
|
||||||
|
- "apps/backend/uv.lock"
|
||||||
|
- "apps/backend/app/**"
|
||||||
- ".github/workflows/airflow.yml"
|
- ".github/workflows/airflow.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -40,10 +48,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: etl/airflow/uv.lock
|
cache-dependency-glob: etl/airflow/uv.lock
|
||||||
@@ -71,9 +79,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Construit l'image (contexte à la racine, elle COPY ml/)
|
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
|
||||||
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
|
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
|
# Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent
|
||||||
@@ -82,3 +90,15 @@ jobs:
|
|||||||
run: >
|
run: >
|
||||||
docker run --rm --network none enervision-airflow:ci
|
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"
|
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 commandes des DAGs `alertes`, historique et API Mock sont couvertes.
|
||||||
|
- name: Vérifie que les quatre 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
|
||||||
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.mock_api_import --help"
|
||||||
@@ -30,10 +30,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -90,10 +90,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -126,10 +126,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -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@v7
|
||||||
|
|
||||||
|
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||||
|
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
|
||||||
|
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
|
||||||
|
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
|
||||||
|
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||||
|
run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium
|
||||||
|
|
||||||
|
# Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace.
|
||||||
|
- name: Rapport complet, tous niveaux
|
||||||
|
continue-on-error: true
|
||||||
|
run: uvx bandit==1.9.4 --recursive app
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
name: DAST
|
||||||
|
|
||||||
|
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
|
||||||
|
# la base et le backend sur le runner, sème un site et quelques relevés (sans ça le scan ne
|
||||||
|
# frappe que des gestionnaires d'erreur), crée un compte `lecteur` jetable
|
||||||
|
# (scripts/dast-token.sh), puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte.
|
||||||
|
#
|
||||||
|
# Non bloquant pour l'instant sur les alertes (`continue-on-error` sur la seule étape du scan) :
|
||||||
|
# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
|
||||||
|
# scan n'a rien testé (import du contrat, absence de toute réponse de succès) : un job vert doit
|
||||||
|
# vouloir dire qu'un scan a eu lieu.
|
||||||
|
#
|
||||||
|
# Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas
|
||||||
|
# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et
|
||||||
|
# remontera des alertes (HSTS absent...) qui n'existent pas derrière lui.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
# Un scan actif est long : hebdomadaire plutôt qu'à chaque PR.
|
||||||
|
- cron: "0 3 * * 1"
|
||||||
|
pull_request:
|
||||||
|
# Ne se lance sur une PR que si le scan lui-même change.
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/dast.yml"
|
||||||
|
- "scripts/dast-token.sh"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dast-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
zap:
|
||||||
|
name: Scan OWASP ZAP de l'API
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Généreux face aux ~2 minutes observées de bout en bout : le vrai plafond est
|
||||||
|
# `scanner.maxScanDurationInMins` (étape Scan ZAP), sous le TTL du jeton. Une annulation par
|
||||||
|
# ce timeout-ci n'exécute pas les étapes `always()` : mieux vaut ne jamais l'atteindre.
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
# Même image que docker-compose.yml : la première migration refuse de s'appliquer sans
|
||||||
|
# l'extension TimescaleDB (cf. backend.yml).
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: timescale/timescaledb-ha:pg17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: enervision
|
||||||
|
POSTGRES_PASSWORD: change_me
|
||||||
|
POSTGRES_DB: enervision_dast
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U enervision -d enervision_dast"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 12
|
||||||
|
--health-start-period 40s
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Base jetable : ZAP y écrira et le script y crée deux comptes.
|
||||||
|
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_dast
|
||||||
|
APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur
|
||||||
|
APP_ENV: local
|
||||||
|
# Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut).
|
||||||
|
# 3600 est le plafond accepté par la configuration ; `scanner.maxScanDurationInMins`
|
||||||
|
# (étape Scan ZAP) reste très en dessous, marge comprise pour les étapes qui l'entourent.
|
||||||
|
APP_ACCESS_TOKEN_TTL_SECONDS: "3600"
|
||||||
|
PGPASSWORD: change_me
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Installe uv
|
||||||
|
# Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
|
||||||
|
# contrairement à actions/checkout ou actions/upload-artifact, premières parties).
|
||||||
|
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
|
# `prune-cache` vaut `true` par défaut (encore sur ce commit) : l'étape de post-job
|
||||||
|
# « Pruning cache » est restée bloquée 5 minutes avant d'échouer (exit code 2) sur un
|
||||||
|
# run où les 16 étapes précédentes passaient, sans lien avec le scan. Le prune n'est
|
||||||
|
# qu'une optimisation de taille de cache entre deux runs, pas une garantie : le
|
||||||
|
# désactiver retire le blocage sans rien changer au comportement du job.
|
||||||
|
prune-cache: false
|
||||||
|
|
||||||
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
|
run: uv python install
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
# `--no-build` : aucune dépendance n'est construite depuis ses sources, donc aucun script de
|
||||||
|
# build exécuté (règle Sonar S8541). Le projet lui-même n'est pas installé : il tourne depuis
|
||||||
|
# `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen
|
||||||
|
# --no-sync` pour ne rien résoudre ni reconstruire (règle S8544).
|
||||||
|
- name: Synchronise les dépendances sans dévier du verrou
|
||||||
|
run: uv sync --frozen --no-dev --no-install-project --no-build
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
- name: Active TimescaleDB sur la base du scan
|
||||||
|
run: psql -h localhost -p 5433 -U enervision -d enervision_dast -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
|
||||||
|
|
||||||
|
- name: Applique les migrations
|
||||||
|
run: uv run --frozen --no-sync --no-build alembic upgrade head
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
# Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne
|
||||||
|
# frappe que des gestionnaires d'erreur plutôt que la logique métier. `db/seeds/` est vide
|
||||||
|
# (pas encore d'outillage de jeu de données pour la CI) : un site et deux relevés à la main,
|
||||||
|
# juste assez pour que les routes de lecture aient quelque chose à rendre.
|
||||||
|
- name: Insère un site et des relevés minimaux pour le scan
|
||||||
|
run: |
|
||||||
|
psql -h localhost -p 5433 -U enervision -d enervision_dast <<'SQL'
|
||||||
|
INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status)
|
||||||
|
VALUES ('dast-site', 'Site du scan DAST', 'bureau', 'CI', 50, 'actif')
|
||||||
|
ON CONFLICT (site_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO reading (site_id, timestamp, source, consumption_kw, consumption_kwh, is_working_hours, data_quality, raw_data)
|
||||||
|
VALUES
|
||||||
|
('dast-site', now() - interval '2 hours', 'api_current', 12.5, 12.5, true, 'good', '{}'),
|
||||||
|
('dast-site', now() - interval '1 hour', 'api_current', 13.0, 13.0, true, 'good', '{}')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
- name: Démarre l'API
|
||||||
|
run: |
|
||||||
|
nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \
|
||||||
|
--host 0.0.0.0 --port 8000 > "$RUNNER_TEMP/api.log" 2>&1 &
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -fsS http://localhost:8000/api/v1/health/ready >/dev/null 2>&1 && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "L'API ne répond pas sur /health/ready" >&2
|
||||||
|
cat "$RUNNER_TEMP/api.log" >&2
|
||||||
|
exit 1
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
- name: Crée le compte lecteur du scan
|
||||||
|
id: jeton
|
||||||
|
run: |
|
||||||
|
jeton="$(../../scripts/dast-token.sh)"
|
||||||
|
echo "::add-mask::$jeton"
|
||||||
|
echo "jeton=$jeton" >> "$GITHUB_OUTPUT"
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
# Étape distincte du scan lui-même, et sans `continue-on-error` : un `curl` qui échoue ici
|
||||||
|
# (API tombée juste après la sonde de readiness, par exemple) doit rester un échec visible,
|
||||||
|
# pas se travestir en « ZAP n'a importé aucune URL » à l'étape de garde suivante.
|
||||||
|
- name: Prépare le contrat pour ZAP
|
||||||
|
run: |
|
||||||
|
mkdir -p zap-out zap-logs
|
||||||
|
curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json
|
||||||
|
# Le dossier passe à l'uid 1000 (utilisateur du conteneur ZAP) : le runner n'y écrit
|
||||||
|
# plus après ce chown, d'où `zap-logs/` (uid du runner) pour les journaux ci-dessous.
|
||||||
|
# Pas de `chmod 777` (règle Sonar S2612).
|
||||||
|
sudo chown -R 1000:1000 zap-out
|
||||||
|
|
||||||
|
# `--network host` : ZAP atteint l'API sur le localhost du runner.
|
||||||
|
#
|
||||||
|
# Piège vécu : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte
|
||||||
|
# n'importe quelle clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les
|
||||||
|
# requêtes un en-tête au nom vide (`: Bearer <jeton>`), qu'uvicorn refuse par un 400
|
||||||
|
# (« Invalid HTTP request received »), y compris sur les routes publiques.
|
||||||
|
#
|
||||||
|
# Le jeton ne passe ni par `${{ }}` dans ce script (il finirait en clair dans le fichier de
|
||||||
|
# commande que GitHub écrit sur le disque du runner pour toute la durée de l'étape), ni par
|
||||||
|
# l'argv de `docker run` (visible par `ps aux` et par `docker inspect zap` tant que le
|
||||||
|
# conteneur existe) : il est écrit dans un fichier de configuration ZAP séparé, monté en
|
||||||
|
# lecture seule hors de `/zap/wrk` pour ne jamais atterrir dans l'artefact publié.
|
||||||
|
#
|
||||||
|
# Les routes d'authentification qui changent l'état du compte du scan sont exclues : un
|
||||||
|
# scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de
|
||||||
|
# passe et la fermeture des sessions, sans rien apprendre de plus.
|
||||||
|
#
|
||||||
|
# `scanner.maxScanDurationInMins`/`maxRuleDurationInMins` bornent le scan actif, que `-T` ne
|
||||||
|
# couvre pas (il ne borne que le démarrage et le scan passif) : sans ça, une règle qui
|
||||||
|
# traîne peut dépasser le TTL du jeton (401 muets en fin de scan) ou le timeout du job (qui
|
||||||
|
# annule sans exécuter les étapes `always()`, rapport et journaux perdus).
|
||||||
|
- name: Scan ZAP
|
||||||
|
id: zap
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
JETON: ${{ steps.jeton.outputs.jeton }}
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
printf 'replacer.full_list(0).description=auth\nreplacer.full_list(0).enabled=true\nreplacer.full_list(0).matchtype=REQ_HEADER\nreplacer.full_list(0).matchstr=Authorization\nreplacer.full_list(0).regex=false\nreplacer.full_list(0).replacement=Bearer %s\n' "$JETON" > "$RUNNER_TEMP/zap-auth.conf"
|
||||||
|
# Piège vécu : `chmod 600` seul rend le fichier illisible pour le conteneur, qui lit un
|
||||||
|
# montage bind avec son propre uid (1000), distinct de celui du runner qui l'a écrit.
|
||||||
|
# ZAP échoue alors dès le lancement (« File not readable: /zap/auth.conf »), et
|
||||||
|
# `zap-api-scan.py` attend `-T` minutes complètes avant d'abandonner : dix minutes qui
|
||||||
|
# ressemblent à un scan actif, pour un daemon mort depuis le début.
|
||||||
|
#
|
||||||
|
# Piège vécu (numéro deux) : une fois le fichier passé à l'uid 1000 par `sudo chown`,
|
||||||
|
# l'utilisateur du runner n'en est plus propriétaire et un `chmod` sans `sudo` échoue
|
||||||
|
# (« Operation not permitted »). Avec le `-e` implicite de bash sur les étapes GitHub
|
||||||
|
# Actions, cette erreur arrêtait toute l'étape avant même `docker run` : scan « réussi »
|
||||||
|
# en une fraction de seconde, sans le moindre journal ni rapport produit.
|
||||||
|
sudo chown 1000:1000 "$RUNNER_TEMP/zap-auth.conf"
|
||||||
|
sudo chmod 644 "$RUNNER_TEMP/zap-auth.conf"
|
||||||
|
docker run --name zap --network host \
|
||||||
|
-v "$PWD/zap-out:/zap/wrk:rw" \
|
||||||
|
-v "$RUNNER_TEMP/zap-auth.conf:/zap/auth.conf:ro" \
|
||||||
|
ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
|
||||||
|
-t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \
|
||||||
|
-T 10 \
|
||||||
|
-r zap-report.html -J zap-report.json -w zap-report.md \
|
||||||
|
-z "-configfile /zap/auth.conf \
|
||||||
|
-config globalexcludeurl.url_list.url(0).description=auth-etat \
|
||||||
|
-config globalexcludeurl.url_list.url(0).enabled=true \
|
||||||
|
-config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*' \
|
||||||
|
-config scanner.maxScanDurationInMins=15 \
|
||||||
|
-config scanner.maxRuleDurationInMins=5" \
|
||||||
|
2>&1 | tee "$RUNNER_TEMP/zap-stdout.log"
|
||||||
|
|
||||||
|
- name: Récupère les journaux de ZAP
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
mkdir -p zap-logs
|
||||||
|
# ZAP journalise la valeur de chaque `-config`/`-configfile` chargé, y compris le jeton,
|
||||||
|
# à un niveau visible sans `-d` : les copies publiées en artefact sont donc caviardées,
|
||||||
|
# même si `::add-mask::` (posé à la création du jeton) protège déjà le journal du job.
|
||||||
|
masque() { sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; }
|
||||||
|
[ -f "$RUNNER_TEMP/zap-stdout.log" ] && masque < "$RUNNER_TEMP/zap-stdout.log" > zap-logs/zap-stdout.log
|
||||||
|
docker cp zap:/home/zap/.ZAP/zap.log "$RUNNER_TEMP/zap-internal.log" 2>/dev/null || true
|
||||||
|
[ -f "$RUNNER_TEMP/zap-internal.log" ] && masque < "$RUNNER_TEMP/zap-internal.log" > zap-logs/zap.log
|
||||||
|
[ -f "$RUNNER_TEMP/api.log" ] && masque < "$RUNNER_TEMP/api.log" > zap-logs/api.log
|
||||||
|
rm -f "$RUNNER_TEMP/zap-auth.conf"
|
||||||
|
docker rm -f zap >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien
|
||||||
|
# testé. Constaté une première fois : 2 URL importées sur 26 opérations, ZAP n'avait envoyé
|
||||||
|
# que des requêtes vouées au 404. Le seuil est dérivé du contrat plutôt que d'un nombre fixe
|
||||||
|
# : un contrat qui grossit ne doit pas rendre la garde plus permissive qu'elle ne l'était.
|
||||||
|
- name: Vérifie que le contrat a bien été importé
|
||||||
|
run: |
|
||||||
|
attendu="$(python3 -c "
|
||||||
|
import json
|
||||||
|
d = json.load(open('zap-out/openapi.json'))
|
||||||
|
methodes = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options')
|
||||||
|
print(sum(1 for chemin in d['paths'].values() for m in chemin if m in methodes))
|
||||||
|
")"
|
||||||
|
minimum=$((attendu * 80 / 100))
|
||||||
|
importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/zap-stdout.log" | tail -1)"
|
||||||
|
echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune} (contrat : $attendu opérations, minimum accepté : $minimum)"
|
||||||
|
if [ "${importees:-0}" -lt "$minimum" ]; then
|
||||||
|
echo "::error::ZAP n'a importé que ${importees:-0} URL sur $attendu opérations du contrat OpenAPI (minimum attendu : $minimum, soit 80%). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des erreurs
|
||||||
|
# (constaté : base sans données, toutes les routes de site répondaient 404).
|
||||||
|
#
|
||||||
|
# Piège de conception, trouvé en répétant ce job en local avant de l'écrire ici : borner le
|
||||||
|
# pourcentage de 4xx ne marche pas. Un scan actif fuzze délibérément un grand nombre
|
||||||
|
# d'entrées invalides (identifiants inventés, méthodes non supportées...), donc même un scan
|
||||||
|
# sain, contre l'API seedée juste au-dessus, reste à 98% de 4xx avec seulement 1% de 2xx :
|
||||||
|
# c'est la forme normale d'un scan actif, pas un signe d'échec. Le signal qui distingue
|
||||||
|
# vraiment un scan cassé (0% de 2xx, `insight.code.2xx` absent du rapport dans le premier
|
||||||
|
# incident) d'un scan sain (2xx non nul, aussi faible soit-il) est donc l'absence de succès,
|
||||||
|
# pas la part d'échecs. Dérivé de `zap-report.json` (champ structuré `insights[]`) plutôt
|
||||||
|
# que du texte libre du rapport Markdown, qui aurait le même défaut de conception en plus
|
||||||
|
# d'être fragile au format.
|
||||||
|
- name: Vérifie que le scan a obtenu au moins une réponse de succès
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
rapport = json.load(open("zap-out/zap-report.json"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("::error::Aucun rapport ZAP produit : le scan n'a rien testé.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
pourcentage_2xx = 0.0
|
||||||
|
for insight in rapport.get("insights", []):
|
||||||
|
if insight.get("key") == "insight.code.2xx":
|
||||||
|
pourcentage_2xx = float(insight.get("statistic", 0))
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"Pourcentage de réponses 2xx : {pourcentage_2xx}%")
|
||||||
|
if pourcentage_2xx <= 0:
|
||||||
|
print(
|
||||||
|
"::error::Aucune réponse 2xx (succès) reçue : le scan n'a atteint aucune route "
|
||||||
|
"réelle de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact "
|
||||||
|
"zap-report."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# Uniquement la synthèse (jusqu'à « Alert Detail » exclu) : `$GITHUB_STEP_SUMMARY` est
|
||||||
|
# limité à 1 Mio, et cette étape tourne sous `always()` - son échec ferait échouer le job
|
||||||
|
# après le passage des deux garde-fous, pour une simple raison de mise en forme. Le rapport
|
||||||
|
# complet reste dans l'artefact `zap-report`.
|
||||||
|
- name: Publie le résumé
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f zap-out/zap-report.md ]; then
|
||||||
|
awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
else
|
||||||
|
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Publie les rapports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: zap-report
|
||||||
|
path: |
|
||||||
|
zap-out/
|
||||||
|
zap-logs/
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# Diagnostic de dernier recours : les journaux de l'API sont déjà dans l'artefact
|
||||||
|
# (zap-logs/api.log) via l'étape « Récupère les journaux de ZAP » (always()), mais les
|
||||||
|
# afficher directement dans le journal du job évite d'avoir à le télécharger pour un échec
|
||||||
|
# évident (l'API n'a jamais démarré, par exemple).
|
||||||
|
- name: Journal de l'API en cas d'échec
|
||||||
|
if: failure() || steps.zap.outcome == 'failure'
|
||||||
|
run: cat "$RUNNER_TEMP/api.log" || true
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Pourquoi : le runner tourne sur la VM ENI, adresse privée que les runners hébergés par GitHub
|
||||||
|
# ne joignent pas, et travaille dans un dossier stable par environnement plutôt que dans son
|
||||||
|
# espace de travail : `.env`, certificats et volumes y survivent d'un déploiement à l'autre.
|
||||||
|
# Piège : jamais de déclencheur `pull_request` ici. Sur un dépôt public, une PR de fork
|
||||||
|
# exécuterait son code sur la machine de production (ADR 0009) - job deploy.
|
||||||
|
|
||||||
|
name: Déploiement
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: [self-hosted, linux, eni-g3]
|
||||||
|
timeout-minutes: 30
|
||||||
|
environment:
|
||||||
|
name: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
|
||||||
|
url: ${{ github.ref_name == 'main' && 'https://enervision.local' || 'https://rec.enervision.local:8443' }}
|
||||||
|
env:
|
||||||
|
ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
|
||||||
|
PORT_HTTPS: ${{ github.ref_name == 'main' && '443' || '8443' }}
|
||||||
|
steps:
|
||||||
|
- name: Aligner le dossier de l'environnement sur la branche poussée
|
||||||
|
run: |
|
||||||
|
cd "/srv/enervision/${ENVIRONNEMENT}"
|
||||||
|
git fetch --quiet origin "${GITHUB_REF_NAME}"
|
||||||
|
git checkout --quiet "${GITHUB_REF_NAME}"
|
||||||
|
git reset --quiet --hard "origin/${GITHUB_REF_NAME}"
|
||||||
|
git log -1 --format='%h %s'
|
||||||
|
|
||||||
|
- name: Reconstruire et redémarrer la stack
|
||||||
|
run: |
|
||||||
|
cd "/srv/enervision/${ENVIRONNEMENT}"
|
||||||
|
make stack-up
|
||||||
|
|
||||||
|
- name: Attendre que l'API réponde derrière le proxy
|
||||||
|
run: |
|
||||||
|
for tentative in $(seq 1 36); do
|
||||||
|
if curl --fail --silent --insecure "https://localhost:${PORT_HTTPS}/api/v1/health/ready"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "L'API ne répond pas après 3 minutes" >&2
|
||||||
|
cd "/srv/enervision/${ENVIRONNEMENT}"
|
||||||
|
compose="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||||
|
$compose ps
|
||||||
|
$compose logs --tail=50 backend proxy
|
||||||
|
exit 1
|
||||||
@@ -17,10 +17,10 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
|
|
||||||
@@ -33,10 +33,10 @@ jobs:
|
|||||||
name: Audit des dépendances
|
name: Audit des dépendances
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
|
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
|
||||||
- run: npm audit --audit-level=high --package-lock-only
|
- run: npm audit --audit-level=high --package-lock-only
|
||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
@@ -45,10 +45,10 @@ jobs:
|
|||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
- name : Installation des dépendances (Front)
|
- name : Installation des dépendances (Front)
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
path: apps/frontend/coverage/frontend/lcov.info
|
path: apps/frontend/coverage/frontend/lcov.info
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
name: Infra
|
||||||
|
|
||||||
|
# Pourquoi : le Terraform du dépôt est resté cassé sans que rien ne le dise, faute de job qui le
|
||||||
|
# joue. Ce workflow n'applique rien : il vérifie le formatage et la validité de chaque racine.
|
||||||
|
# Piège : la boucle parcourt `environments/*`, pour qu'une racine ajoutée soit couverte sans
|
||||||
|
# toucher à ce fichier.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "infra/terraform/**"
|
||||||
|
- ".github/workflows/infra.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "infra/terraform/**"
|
||||||
|
- ".github/workflows/infra.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: infra-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
terraform:
|
||||||
|
name: Formatage et validation Terraform
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
# Action tierce, donc epinglee sur un SHA de commit et pas sur un tag mobile : un tag se
|
||||||
|
# redeplace, et ce workflow tourne avec les droits du depot (regle Sonar githubactions:S7637).
|
||||||
|
- name: Installe Terraform
|
||||||
|
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||||
|
with:
|
||||||
|
terraform_version: 1.16.3
|
||||||
|
terraform_wrapper: false
|
||||||
|
|
||||||
|
- name: Vérifie le formatage
|
||||||
|
run: terraform fmt -check -recursive infra/terraform
|
||||||
|
|
||||||
|
- name: Valide chaque racine
|
||||||
|
run: |
|
||||||
|
for racine in infra/terraform/environments/*/; do
|
||||||
|
echo "::group::${racine}"
|
||||||
|
terraform -chdir="${racine}" init -backend=false -input=false
|
||||||
|
terraform -chdir="${racine}" validate
|
||||||
|
echo "::endgroup::"
|
||||||
|
done
|
||||||
+125
-4
@@ -8,10 +8,28 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "ml/**"
|
- "ml/**"
|
||||||
- ".github/workflows/ml.yml"
|
- ".github/workflows/ml.yml"
|
||||||
|
# Le job `integration` monte son schema avec les migrations du backend et joue le test de
|
||||||
|
# chaine qui vit dans ses tests : sans ces chemins, une migration modifiee ne declencherait
|
||||||
|
# rien et le schema deriverait du SQL du pipeline sans que rien ne casse. Meme raisonnement
|
||||||
|
# que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/.
|
||||||
|
- "apps/backend/alembic/**"
|
||||||
|
- "apps/backend/app/models/**"
|
||||||
|
- "apps/backend/tests/test_chaine_ml_api.py"
|
||||||
|
- "apps/backend/pyproject.toml"
|
||||||
|
- "apps/backend/uv.lock"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "ml/**"
|
- "ml/**"
|
||||||
- ".github/workflows/ml.yml"
|
- ".github/workflows/ml.yml"
|
||||||
|
# Le job `integration` monte son schema avec les migrations du backend et joue le test de
|
||||||
|
# chaine qui vit dans ses tests : sans ces chemins, une migration modifiee ne declencherait
|
||||||
|
# rien et le schema deriverait du SQL du pipeline sans que rien ne casse. Meme raisonnement
|
||||||
|
# que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/.
|
||||||
|
- "apps/backend/alembic/**"
|
||||||
|
- "apps/backend/app/models/**"
|
||||||
|
- "apps/backend/tests/test_chaine_ml_api.py"
|
||||||
|
- "apps/backend/pyproject.toml"
|
||||||
|
- "apps/backend/uv.lock"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -30,10 +48,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: ml/uv.lock
|
cache-dependency-glob: ml/uv.lock
|
||||||
@@ -53,7 +71,110 @@ jobs:
|
|||||||
- name: Typage
|
- name: Typage
|
||||||
run: uv run mypy enervision_ml tests
|
run: uv run mypy enervision_ml tests
|
||||||
|
|
||||||
# Aucun test ne touche PostgreSQL ni MLflow distant : tout tourne sur donnees
|
# Les tests exigeant une base portent le marqueur `integration`, ecarte par defaut et
|
||||||
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
|
# joue par le job `integration` ci-dessous.
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: uv run pytest
|
run: uv run pytest
|
||||||
|
|
||||||
|
# Le seul job du depot qui dispose a la fois des deux environnements uv et d'une base. Piege :
|
||||||
|
# le schema de la base ML est celui du backend (apps/backend/alembic, proprietaire du schema).
|
||||||
|
# Le reconstruire ici a la main rendrait ce job vert sur une base qui n'est pas la notre.
|
||||||
|
integration:
|
||||||
|
name: ML - DB et chaîne ML - DB - API
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: timescale/timescaledb-ha:pg17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: enervision
|
||||||
|
POSTGRES_PASSWORD: change_me
|
||||||
|
POSTGRES_DB: enervision_test
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U enervision -d enervision_test"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 12
|
||||||
|
--health-start-period 40s
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Deux variables, deux dialectes : Alembic et l'API parlent asyncpg, le pipeline ML parle
|
||||||
|
# psycopg en synchrone. Cf. docs/ML-START.md, section 1.
|
||||||
|
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test
|
||||||
|
ML_DATABASE_URL: postgresql+psycopg://enervision:change_me@localhost:5433/enervision_test
|
||||||
|
APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur
|
||||||
|
PGPASSWORD: change_me
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: |
|
||||||
|
ml/uv.lock
|
||||||
|
apps/backend/uv.lock
|
||||||
|
|
||||||
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
|
working-directory: ml
|
||||||
|
run: uv python install
|
||||||
|
|
||||||
|
- name: Synchronise le pipeline ML sans dévier du verrou
|
||||||
|
working-directory: ml
|
||||||
|
run: uv sync --all-groups --frozen
|
||||||
|
|
||||||
|
# Le backend est installé ici parce qu'il porte les migrations, seule source du schéma, et
|
||||||
|
# le test de chaîne, qui interroge l'API.
|
||||||
|
- name: Synchronise le backend sans dévier du verrou
|
||||||
|
working-directory: apps/backend
|
||||||
|
run: uv sync --all-groups --frozen
|
||||||
|
|
||||||
|
# db/init/110-test-database.sql n'est pas monté ici, et sans l'extension la première
|
||||||
|
# révision Alembic refuse de s'appliquer.
|
||||||
|
- name: Active TimescaleDB sur la base de test
|
||||||
|
run: psql -h localhost -p 5433 -U enervision -d enervision_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
|
||||||
|
|
||||||
|
- name: Applique les migrations du backend, propriétaire du schéma
|
||||||
|
working-directory: apps/backend
|
||||||
|
run: uv run alembic upgrade head
|
||||||
|
|
||||||
|
# `-m` en ligne de commande écrase celui d'addopts. Couverture désactivée : ce job ne joue
|
||||||
|
# qu'une partie de la suite, son taux n'aurait pas de sens (même raison que backend.yml).
|
||||||
|
- name: Tests ML exigeant une base
|
||||||
|
working-directory: ml
|
||||||
|
run: uv run pytest -m integration --no-cov
|
||||||
|
|
||||||
|
# Lance les vrais binaires enervision_ml.train et .score en sous-processus, comme les DAGs
|
||||||
|
# ml_train et ml_score, puis relit le résultat par GET /api/v1/predictions.
|
||||||
|
- name: Chaîne complète ML vers DB vers API
|
||||||
|
working-directory: apps/backend
|
||||||
|
env:
|
||||||
|
ML_PYTHON: ${{ github.workspace }}/ml/.venv/bin/python
|
||||||
|
run: uv run pytest -m chaine --no-cov
|
||||||
|
|
||||||
|
sast:
|
||||||
|
name: Analyse statique de sécurité
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||||
|
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
|
||||||
|
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||||
|
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
|
||||||
|
|
||||||
|
- name: Rapport complet, tous niveaux
|
||||||
|
continue-on-error: true
|
||||||
|
run: uvx bandit==1.9.4 --recursive enervision_ml
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
@@ -20,10 +24,10 @@ jobs:
|
|||||||
build-front:
|
build-front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
|
|
||||||
@@ -36,10 +40,10 @@ jobs:
|
|||||||
needs: build-front
|
needs: build-front
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
|
|
||||||
@@ -52,7 +56,7 @@ jobs:
|
|||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
path: apps/frontend/coverage/frontend/lcov.info
|
path: apps/frontend/coverage/frontend/lcov.info
|
||||||
@@ -60,9 +64,9 @@ jobs:
|
|||||||
build-back:
|
build-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -91,9 +95,9 @@ jobs:
|
|||||||
needs: build-back
|
needs: build-back
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -103,29 +107,65 @@ jobs:
|
|||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
path: apps/backend/coverage.xml
|
path: apps/backend/coverage.xml
|
||||||
|
|
||||||
sonarqube:
|
test-ml:
|
||||||
needs: [build-front, build-back, test-front, test-back]
|
|
||||||
name: SonarQube
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: ml/uv.lock
|
||||||
|
|
||||||
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
|
run: uv python install
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Synchronise les dépendances sans dévier du verrou
|
||||||
|
run: uv sync --all-groups --frozen
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Lancement des tests et génération du rapport de couverture (ML)
|
||||||
|
run: uv run pytest --cov-report=xml
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: ml-coverage
|
||||||
|
path: ml/coverage.xml
|
||||||
|
|
||||||
|
sonarqube:
|
||||||
|
needs: [build-front, build-back, test-front, test-back, test-ml]
|
||||||
|
name: SonarQube
|
||||||
|
# Pourquoi : GitHub ne fournit pas les secrets aux workflows lancés par dependabot[bot].
|
||||||
|
# Sans SONAR_TOKEN le scan échoue sans rien analyser ; build et tests restent joués.
|
||||||
|
if: github.actor != 'dependabot[bot]'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Téléchargement du rapport de couverture (Front)
|
- name: Téléchargement du rapport de couverture (Front)
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
path: apps/frontend/coverage/frontend
|
path: apps/frontend/coverage/frontend
|
||||||
- name: Téléchargement du rapport de couverture (Back)
|
- name: Téléchargement du rapport de couverture (Back)
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
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@v8
|
||||||
|
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:
|
||||||
|
|||||||
+5
-2
@@ -40,7 +40,6 @@ kubeconfig
|
|||||||
# Airflow
|
# Airflow
|
||||||
etl/airflow/logs/
|
etl/airflow/logs/
|
||||||
airflow.db
|
airflow.db
|
||||||
airflow-webserver.pid
|
|
||||||
standalone_admin_password.txt
|
standalone_admin_password.txt
|
||||||
|
|
||||||
# Environnement et secrets
|
# Environnement et secrets
|
||||||
@@ -64,11 +63,15 @@ ml/models/*
|
|||||||
!ml/models/.gitkeep
|
!ml/models/.gitkeep
|
||||||
ml/mlruns/
|
ml/mlruns/
|
||||||
ml/mlartifacts/
|
ml/mlartifacts/
|
||||||
ml/mlflow.db
|
ml/mlflow.db*
|
||||||
|
ml/.env
|
||||||
|
|
||||||
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
||||||
etl/airflow/tests/.airflow_home/
|
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/
|
||||||
|
|||||||
@@ -2,14 +2,52 @@ BACKEND := apps/backend
|
|||||||
FRONTEND := apps/frontend
|
FRONTEND := apps/frontend
|
||||||
ML := ml
|
ML := ml
|
||||||
AIRFLOW := etl/airflow
|
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
|
||||||
|
|
||||||
|
# Piege : make ne lit pas `.env`, que seul docker compose interpole. Les cibles hors conteneur
|
||||||
|
# (ml-*, demo-data, db-wait) joignent la base par le port publie et ont besoin de ces valeurs.
|
||||||
|
env-val = $(shell sed -n 's/^$(1)=//p' .env 2>/dev/null | tail -1)
|
||||||
|
PG_USER := $(or $(strip $(call env-val,POSTGRES_USER)),enervision)
|
||||||
|
PG_PASSWORD := $(or $(strip $(call env-val,POSTGRES_PASSWORD)),change_me)
|
||||||
|
PG_DB := $(or $(strip $(call env-val,POSTGRES_DB)),enervision)
|
||||||
|
PG_PORT := $(or $(strip $(call env-val,POSTGRES_PORT)),5433)
|
||||||
|
ml-env-val = $(shell sed -n 's/^$(1)=//p' ml/.env 2>/dev/null | tail -1)
|
||||||
|
ML_ENV_DB_PASSWORD := $(call ml-env-val,MLFLOW_DB_PASSWORD)
|
||||||
|
AIRFLOW_PORT := $(or $(strip $(call env-val,AIRFLOW_PORT)),8080)
|
||||||
|
MAILPIT_UI_PORT := $(or $(strip $(call env-val,MAILPIT_UI_PORT)),8025)
|
||||||
|
ML_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_DB)
|
||||||
|
export ML_DATABASE_URL
|
||||||
|
|
||||||
|
# Piege : la base des tests d'integration n'est pas la base de developpement. Ces tests ecrivent
|
||||||
|
# et suppriment des lignes, et leurs fixtures refusent de demarrer ailleurs que sur
|
||||||
|
# `enervision_test` (garde sur le nom, cf. ml/tests/conftest.py).
|
||||||
|
PG_TEST_DB ?= enervision_test
|
||||||
|
TEST_DATABASE_URL ?= postgresql+asyncpg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB)
|
||||||
|
ML_TEST_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB)
|
||||||
|
|
||||||
|
# Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne
|
||||||
|
# verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score.
|
||||||
|
DEMO_NOW ?= 2024-12-31T00:00:00Z
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
||||||
dev dev-backend dev-frontend \
|
dev dev-backend dev-frontend \
|
||||||
lint format typecheck test test-cov test-integration check \
|
lint format typecheck test test-cov test-integration ml-test-integration \
|
||||||
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
|
test-chaine check \
|
||||||
ml-lint ml-typecheck ml-test ml-check ml-train ml-score recommendations \
|
openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \
|
||||||
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs
|
migrate migrate-test bootstrap-admin services-up demo-data demo-data-force \
|
||||||
|
ml-lint ml-typecheck ml-test ml-check ml-train ml-score mlflow-up 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}'
|
||||||
@@ -28,12 +66,19 @@ install-ml: ## Installe les dépendances du pipeline ML
|
|||||||
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
|
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
|
||||||
cd $(AIRFLOW) && uv sync --all-groups
|
cd $(AIRFLOW) && uv sync --all-groups
|
||||||
|
|
||||||
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
|
dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airflow, puis backend et frontend
|
||||||
|
@echo "airflow -> http://localhost:$(AIRFLOW_PORT) mailpit -> http://localhost:$(MAILPIT_UI_PORT)"
|
||||||
@trap 'kill 0' EXIT INT TERM; \
|
@trap 'kill 0' EXIT INT TERM; \
|
||||||
$(MAKE) --no-print-directory dev-backend & \
|
$(MAKE) --no-print-directory dev-backend & \
|
||||||
$(MAKE) --no-print-directory dev-frontend & \
|
$(MAKE) --no-print-directory dev-frontend & \
|
||||||
wait
|
wait
|
||||||
|
|
||||||
|
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow)
|
||||||
|
docker compose up -d db mailpit
|
||||||
|
@$(MAKE) --no-print-directory db-wait
|
||||||
|
@$(MAKE) --no-print-directory db-ensure-airflow
|
||||||
|
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
|
||||||
|
|
||||||
dev-backend: ## Lance l'API seule en rechargement à chaud
|
dev-backend: ## Lance l'API seule en rechargement à chaud
|
||||||
@echo "backend -> http://localhost:8000 (docs sur /docs)"
|
@echo "backend -> http://localhost:8000 (docs sur /docs)"
|
||||||
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
|
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
|
||||||
@@ -77,11 +122,32 @@ ml-test: ## Exécute les tests du pipeline ML (donnees synthetiques, sans base n
|
|||||||
|
|
||||||
ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML
|
ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML
|
||||||
|
|
||||||
|
# La cible surcharge ML_DATABASE_URL, que ce Makefile exporte vers la base de développement : la
|
||||||
|
# garde du conftest ferait échouer la cible sans cette surcharge.
|
||||||
|
ml-test-integration: ML_DATABASE_URL := $(ML_TEST_DATABASE_URL)
|
||||||
|
ml-test-integration: ## Tests ML exigeant une base migrée. Faire `make db-up migrate-test` avant
|
||||||
|
cd $(ML) && uv run pytest -m integration --no-cov
|
||||||
|
|
||||||
|
test-chaine: ## Chaîne ML -> DB -> API, vrais binaires. Exige les deux environnements uv
|
||||||
|
cd $(BACKEND) && DATABASE_URL=$(TEST_DATABASE_URL) ML_PYTHON=$(CURDIR)/$(ML)/.venv/bin/python \
|
||||||
|
uv run pytest -m chaine --no-cov
|
||||||
|
|
||||||
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
|
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
|
||||||
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
|
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
|
||||||
|
|
||||||
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
|
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV= et NOW= optionnels
|
||||||
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),) $(if $(NOW),--now $(NOW),)
|
||||||
|
|
||||||
|
mlflow-up: ## Démarre le serveur MLflow (tracking + registry) en conteneur. ml/.env requis
|
||||||
|
@test -n "$(strip $(ML_ENV_DB_PASSWORD))" \
|
||||||
|
|| { echo "MLFLOW_DB_PASSWORD absente de ml/.env (copier ml/.env.example)"; exit 1; }
|
||||||
|
@echo "$(ML_ENV_DB_PASSWORD)" | grep -qE '^[A-Za-z0-9]+$$' \
|
||||||
|
|| { echo "MLFLOW_DB_PASSWORD doit contenir uniquement lettres et chiffres (interpolee dans l'URI postgresql://)"; exit 1; }
|
||||||
|
cd $(ML) && docker compose -f docker-compose.mlflow.yml up -d --build
|
||||||
|
@echo "mlflow -> http://localhost:5000"
|
||||||
|
|
||||||
|
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
|
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),)
|
cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),)
|
||||||
@@ -94,12 +160,12 @@ airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structur
|
|||||||
|
|
||||||
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
|
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.
|
airflow-up: db-ensure-airflow ## Démarre Airflow (api-server + scheduler + dag-processor, LocalExecutor). db-up requis avant.
|
||||||
docker compose up -d airflow-init airflow-webserver airflow-scheduler
|
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
|
||||||
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
|
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
|
||||||
|
|
||||||
airflow-down: ## Arrête le webserver et le scheduler Airflow
|
airflow-down: ## Arrête l'api-server, le scheduler et le dag-processor Airflow
|
||||||
docker compose stop airflow-webserver airflow-scheduler
|
docker compose stop airflow-apiserver airflow-scheduler airflow-dag-processor
|
||||||
|
|
||||||
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
|
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
|
||||||
docker compose logs -f airflow-scheduler
|
docker compose logs -f airflow-scheduler
|
||||||
@@ -107,6 +173,38 @@ airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâche
|
|||||||
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,)
|
||||||
|
|
||||||
|
# Piège : l'image backend ne migre pas au démarrage, et `/health/ready` ne teste que la connexion
|
||||||
|
# et l'extension. Sans `alembic upgrade head`, la stack démarre verte sur une base sans schéma.
|
||||||
|
stack-up: ## Démarre la stack derrière le reverse proxy, puis migre la base. 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
|
||||||
|
$(COMPOSE_PROD) exec -T backend alembic upgrade head
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -122,8 +220,39 @@ db-logs: ## Suit les journaux de la base
|
|||||||
db-psql: ## Ouvre une session psql sur la base applicative
|
db-psql: ## Ouvre une session psql sur la base applicative
|
||||||
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision}
|
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision}
|
||||||
|
|
||||||
|
db-wait: ## Attend que la base accepte les connexions
|
||||||
|
@for _ in $$(seq 1 60); do \
|
||||||
|
docker compose exec -T db pg_isready -U $(PG_USER) -d $(PG_DB) >/dev/null 2>&1 && exit 0; \
|
||||||
|
sleep 1; \
|
||||||
|
done; \
|
||||||
|
echo "La base n'accepte toujours pas de connexion apres 60s"; exit 1
|
||||||
|
|
||||||
|
# Piege : db/init ne rejoue qu'a la premiere initialisation du volume. Un `pgdata` cree avant
|
||||||
|
# db/init/120-airflow-database.sql n'a pas de base `airflow`, et airflow-init boucle dessus.
|
||||||
|
db-ensure-airflow: ## Crée la base de métadonnées Airflow si le volume pgdata est antérieur à db/init/120
|
||||||
|
@docker compose exec -T db psql -U $(PG_USER) -d postgres -tAc \
|
||||||
|
"SELECT 1 FROM pg_database WHERE datname = 'airflow'" | grep -q 1 \
|
||||||
|
|| docker compose exec -T db psql -U $(PG_USER) -d postgres -c "CREATE DATABASE airflow"
|
||||||
|
|
||||||
migrate: ## Applique les migrations Alembic
|
migrate: ## Applique les migrations Alembic
|
||||||
cd $(BACKEND) && uv run alembic upgrade head
|
cd $(BACKEND) && uv run alembic upgrade head
|
||||||
|
|
||||||
|
migrate-test: ## Applique les migrations sur enervision_test, la base des tests d'intégration
|
||||||
|
cd $(BACKEND) && DATABASE_URL=$(TEST_DATABASE_URL) uv run alembic upgrade head
|
||||||
|
|
||||||
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier
|
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier
|
||||||
cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis}
|
cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis}
|
||||||
|
|
||||||
|
demo-data: ## Renseigne prédictions, alertes et recommandations si elles manquent. NOW= optionnel
|
||||||
|
@nombre=$$(docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -tAc 'SELECT count(*) FROM alert') \
|
||||||
|
|| { echo "demo-data : base injoignable ou migrations non appliquees"; exit 1; }; \
|
||||||
|
if [ "$$nombre" = 0 ]; then \
|
||||||
|
$(MAKE) --no-print-directory demo-data-force; \
|
||||||
|
else \
|
||||||
|
echo "demo-data : $$nombre alerte(s) deja en base (make demo-data-force pour rejouer)"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
demo-data-force: ## Rejoue le peuplement sans regarder l'existant. Les trois etapes sont idempotentes
|
||||||
|
$(MAKE) --no-print-directory ml-score NOW=$(DEMO_NOW)
|
||||||
|
$(MAKE) --no-print-directory detect-alerts NOW=$(DEMO_NOW)
|
||||||
|
$(MAKE) --no-print-directory recommendations
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ series temporelles energetiques, deployee sur une machine on-premise.
|
|||||||
|-------|----------------------------------------------------------|
|
|-------|----------------------------------------------------------|
|
||||||
| J1 | Valider la préparation de l'environnement et du repo |
|
| J1 | Valider la préparation de l'environnement et du repo |
|
||||||
| J2 | Valider le périmètre retenu et les choix technologiques |
|
| J2 | Valider le périmètre retenu et les choix technologiques |
|
||||||
| J3 | Valider l'architecture et la gestion de la sécurité |
|
| J3 | Ingestion & backend |
|
||||||
| J4 | Valider la robustesse et assurer les livrables |
|
| J4 | Architecture, sécurité & frontend |
|
||||||
|
| J5 | Valider la robustesse et assurer les livrables |
|
||||||
|
| J6 | Amélioration possible |
|
||||||
|
|
||||||
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
|
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
|
||||||
|
|
||||||
@@ -18,14 +20,15 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
|||||||
|
|
||||||
| Domaine | Technologie | Emplacement | Etat |
|
| Domaine | Technologie | Emplacement | Etat |
|
||||||
|------------|-------------------------------------|---------------------|---------------|
|
|------------|-------------------------------------|---------------------|---------------|
|
||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
|
||||||
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
| Frontend | Angular 22, Node 26 | `apps/frontend` | En place |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
| ETL | Apache Airflow | `etl/airflow` | Cinq DAGs |
|
||||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
||||||
|
| CI/CD | GitHub Actions | `.github/workflows` | 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` | En place |
|
||||||
|
|
||||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
||||||
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
|
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
|
||||||
@@ -47,13 +50,15 @@ 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, imports, dérive)
|
||||||
│ ├── 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/
|
||||||
│ ├── modules/ Modules reutilisables
|
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
|
||||||
│ └── environments/ Racines Terraform, une par environnement
|
│ └── terraform/
|
||||||
|
│ ├── modules/ Modules reutilisables
|
||||||
|
│ └── environments/ Racines Terraform, une par environnement
|
||||||
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
|
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
|
||||||
├── monitoring/
|
├── monitoring/
|
||||||
│ ├── prometheus/ Collecte et regles d'alerte
|
│ ├── prometheus/ Collecte et regles d'alerte
|
||||||
@@ -72,25 +77,53 @@ Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Pyth
|
|||||||
cp .env.example .env # variables de docker-compose
|
cp .env.example .env # variables de docker-compose
|
||||||
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
|
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
|
||||||
|
|
||||||
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
|
make install # dependances du backend, du frontend, du ML et des DAGs
|
||||||
make install # dependances du backend et du frontend
|
make dev # toute la stack, voir ci-dessous
|
||||||
make migrate # applique les migrations Alembic
|
|
||||||
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
|
|
||||||
make check # lint + typage + tests
|
make check # lint + typage + tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make dev` enchaine tout : demarrage des services conteneurises (base sur le port 5433, Mailpit,
|
||||||
|
Airflow), migrations Alembic, peuplement de demonstration si les alertes manquent, puis backend
|
||||||
|
et frontend en rechargement a chaud sur le poste.
|
||||||
|
|
||||||
|
| Service | Adresse |
|
||||||
|
|---|---|
|
||||||
|
| Backend | <http://localhost:8000> (documentation sur `/docs`) |
|
||||||
|
| Frontend | <http://localhost:4200> |
|
||||||
|
| Airflow | <http://localhost:8080> (`AIRFLOW_ADMIN_USERNAME` / `AIRFLOW_ADMIN_PASSWORD` du `.env`) |
|
||||||
|
| Mailpit | <http://localhost:8025> |
|
||||||
|
|
||||||
|
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
|
||||||
|
`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et
|
||||||
|
`AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`,
|
||||||
|
`airflow-scheduler` et `airflow-dag-processor` avec lui.
|
||||||
|
|
||||||
|
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
|
||||||
|
`make airflow-up`, `make dev-backend`, `make dev-frontend`.
|
||||||
|
|
||||||
`make help` liste les cibles disponibles.
|
`make help` liste les cibles disponibles.
|
||||||
|
|
||||||
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`,
|
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`,
|
||||||
`apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que
|
`apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que
|
||||||
5432, souvent deja pris par une autre base.
|
5432, souvent deja pris par une autre base.
|
||||||
|
|
||||||
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
|
Le backend et le frontend tournent sur le poste, lances ensemble par `make dev` (logs
|
||||||
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par
|
entrelaces dans le meme terminal, Ctrl+C arrete les deux) ; la base, Mailpit et Airflow tournent
|
||||||
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend`
|
en conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
|
||||||
et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend`
|
et n'embarque pas le source, donc toute modification y demande un
|
||||||
du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc
|
`docker compose up -d --build backend`.
|
||||||
toute modification y demande un `docker compose up -d --build backend`.
|
|
||||||
|
### Donnees de demonstration
|
||||||
|
|
||||||
|
Le jeu historique s'arrete au 31/12/2024. `make demo-data` renseigne les tables que les vues
|
||||||
|
alertes, recommandations et previsions lisent, en ancrant le scoring et la detection a cette
|
||||||
|
date (`DEMO_NOW`) plutot qu'a l'horloge reelle, qui ne verrait qu'un parc muet depuis des mois.
|
||||||
|
La cible ne fait rien si des alertes existent deja ; `make demo-data-force` rejoue les trois
|
||||||
|
etapes, toutes idempotentes en base.
|
||||||
|
|
||||||
|
Un volume `pgdata` cree avant `db/init/120-airflow-database.sql` n'a pas de base `airflow` :
|
||||||
|
`db/init` ne rejoue qu'a la premiere initialisation. `make db-ensure-airflow`, appelee par
|
||||||
|
`make dev` et `make airflow-up`, la cree au besoin, sans detruire les donnees applicatives.
|
||||||
|
|
||||||
Verifier que la base repond et que l'extension est chargee :
|
Verifier que la base repond et que l'extension est chargee :
|
||||||
|
|
||||||
@@ -98,6 +131,27 @@ 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).
|
||||||
|
|
||||||
|
Sur la VM ENI, deux environnements cohabitent, recette sur `dev` et production sur `main`,
|
||||||
|
chacun dans son dossier et son projet Compose : `scripts/provision-host.sh` les prépare, le
|
||||||
|
workflow `deploy.yml` les redéploie à chaque push par un runner auto-hébergé. Ports, noms
|
||||||
|
d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et
|
||||||
|
[l'ADR 0009](docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.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.
|
||||||
|
|||||||
+12
-7
@@ -1,3 +1,8 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM docker
|
||||||
|
COPY --from=docker/buildx-bin /buildx /usr/libexec/docker/cli-plugins/docker-buildx
|
||||||
|
RUN docker buildx version
|
||||||
|
|
||||||
FROM python:3.14-slim AS builder
|
FROM python:3.14-slim AS builder
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/
|
||||||
@@ -8,16 +13,16 @@ ENV UV_COMPILE_BYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
COPY uv.lock pyproject.toml /app/
|
||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
RUN uv sync --locked --no-install-project --no-dev
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
|
||||||
uv sync --locked --no-install-project --no-dev
|
|
||||||
|
|
||||||
|
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
|
||||||
|
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
|
||||||
|
# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que
|
||||||
|
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN uv sync --locked --no-dev
|
||||||
uv sync --locked --no-dev
|
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.14-slim AS runtime
|
FROM python:3.14-slim AS runtime
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""rapports de derive du modele de prevision
|
||||||
|
|
||||||
|
Revision ID: d3f1a2b7c904
|
||||||
|
Revises: c0adab96238c
|
||||||
|
Create Date: 2026-09-22 14:40:00.000000
|
||||||
|
|
||||||
|
`site_id` est nullable, et c'est le coeur du schema : une ligne par site, plus une ligne
|
||||||
|
globale tous sites confondus, que `NULL` designe. Un seul site qui derive est invisible dans
|
||||||
|
une moyenne d'ensemble, et une derive d'ensemble sans rupture par site signale un changement
|
||||||
|
de modele ou de saison, pas une panne.
|
||||||
|
|
||||||
|
L'unicite passe par un index a `coalesce` et non par une `UniqueConstraint` : deux lignes
|
||||||
|
globales successives ont toutes deux `site_id` a NULL, et NULL n'est egal a aucune valeur, pas
|
||||||
|
meme a lui-meme. Meme forme que `uq_reading_source`.
|
||||||
|
|
||||||
|
Les trois `CHECK` sont portees par la base, comme `ck_prediction_status` : un verdict sans
|
||||||
|
motif, ou un statut inconnu, ne doit pas dependre de la vigilance de l'appelant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "d3f1a2b7c904"
|
||||||
|
down_revision: str | Sequence[str] | None = "c0adab96238c"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"drift_report",
|
||||||
|
sa.Column("drift_report_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"computed_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("site_id", sa.Text(), nullable=True),
|
||||||
|
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("reference_start", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("reference_end", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("n_observations", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("mae", sa.Double(), nullable=True),
|
||||||
|
sa.Column("mape", sa.Double(), nullable=True),
|
||||||
|
sa.Column("bias", sa.Double(), nullable=True),
|
||||||
|
sa.Column("reference_mae", sa.Double(), nullable=True),
|
||||||
|
sa.Column("coverage_ratio", sa.Double(), nullable=True),
|
||||||
|
sa.Column("insufficient_data_ratio", sa.Double(), nullable=True),
|
||||||
|
sa.Column("model_references", postgresql.ARRAY(sa.Text()), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"status IN ('stable', 'derive', 'indetermine')", name="ck_drift_report_status"
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"status = 'stable' OR reason IS NOT NULL", name="ck_drift_report_reason"
|
||||||
|
),
|
||||||
|
sa.CheckConstraint("n_observations >= 0", name="ck_drift_report_observations"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["site_id"], ["site.site_id"], name="fk_drift_report_site", ondelete="RESTRICT"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("drift_report_id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_drift_report_site_computed", "drift_report", ["site_id", "computed_at"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_drift_report_window",
|
||||||
|
"drift_report",
|
||||||
|
["window_end", sa.literal_column("coalesce(site_id, '')")],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("drift_report")
|
||||||
@@ -24,6 +24,7 @@ from app.core.security import decode_access_token as decode_token
|
|||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.repositories.alert import AlertRepository
|
from app.repositories.alert import AlertRepository
|
||||||
from app.repositories.audit_log import AuditLogRepository
|
from app.repositories.audit_log import AuditLogRepository
|
||||||
|
from app.repositories.drift import DriftRepository
|
||||||
from app.repositories.login_attempt import LoginAttemptRepository
|
from app.repositories.login_attempt import LoginAttemptRepository
|
||||||
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
|
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
|
||||||
from app.repositories.password_reset_token import PasswordResetTokenRepository
|
from app.repositories.password_reset_token import PasswordResetTokenRepository
|
||||||
@@ -35,6 +36,7 @@ from app.repositories.site import SiteRepository
|
|||||||
from app.repositories.user import UserRepository
|
from app.repositories.user import UserRepository
|
||||||
from app.services.alert import AlertService
|
from app.services.alert import AlertService
|
||||||
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
|
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
|
||||||
|
from app.services.drift import DriftService
|
||||||
from app.services.prediction import PredictionService
|
from app.services.prediction import PredictionService
|
||||||
from app.services.reading import ReadingService
|
from app.services.reading import ReadingService
|
||||||
from app.services.recommendation import RecommendationService
|
from app.services.recommendation import RecommendationService
|
||||||
@@ -48,7 +50,9 @@ SettingsDep = Annotated[Settings, Depends(get_settings)]
|
|||||||
|
|
||||||
CODE_CHANGEMENT_REQUIS = "password_change_required"
|
CODE_CHANGEMENT_REQUIS = "password_change_required"
|
||||||
|
|
||||||
_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès")
|
# Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un nom
|
||||||
|
# de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
|
||||||
|
_porteur = HTTPBearer(auto_error=False, scheme_name="JetonAcces")
|
||||||
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
|
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
|
||||||
|
|
||||||
|
|
||||||
@@ -232,6 +236,13 @@ def get_prediction_service(session: SessionDep) -> PredictionService:
|
|||||||
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
|
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_drift_service(session: SessionDep) -> DriftService:
|
||||||
|
return DriftService(DriftRepository(session))
|
||||||
|
|
||||||
|
|
||||||
|
DriftServiceDep = Annotated[DriftService, Depends(get_drift_service)]
|
||||||
|
|
||||||
|
|
||||||
async def get_current_principal(
|
async def get_current_principal(
|
||||||
credentials: CredentialsDep,
|
credentials: CredentialsDep,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
|
|||||||
@@ -90,11 +90,21 @@ TAGS: Final[list[dict[str, Any]]] = [
|
|||||||
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
|
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "monitoring",
|
||||||
|
"description": (
|
||||||
|
"Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et "
|
||||||
|
"les lectures réellement arrivées, par site et tous sites confondus. Réservé à "
|
||||||
|
"partir du rôle `operateur`, qui agit sur un pipeline dégradé."
|
||||||
|
),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
cookie_de_rafraichissement = APIKeyCookie(
|
cookie_de_rafraichissement = APIKeyCookie(
|
||||||
name=REFRESH_COOKIE_DEFAUT,
|
name=REFRESH_COOKIE_DEFAUT,
|
||||||
scheme_name="Cookie de rafraîchissement",
|
# Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un
|
||||||
|
# nom de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
|
||||||
|
scheme_name="CookieRafraichissement",
|
||||||
description=(
|
description=(
|
||||||
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
|
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
|
||||||
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
|
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
|
||||||
@@ -153,6 +163,17 @@ REPONSES_ADMIN: Final[Reponses] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
REPONSES_OPERATEUR: Final[Reponses] = {
|
||||||
|
**REPONSES_AUTHENTIFIEES,
|
||||||
|
403: {
|
||||||
|
"model": ErrorResponse,
|
||||||
|
"description": (
|
||||||
|
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
|
||||||
|
"`password_change_required`."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
|
# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
|
||||||
# insuffisants, seulement pour le mot de passe provisoire.
|
# insuffisants, seulement pour le mot de passe provisoire.
|
||||||
REPONSES_LECTEUR: Final[Reponses] = {
|
REPONSES_LECTEUR: Final[Reponses] = {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import DriftServiceDep, OperateurDep
|
||||||
|
from app.api.openapi import REPONSE_VALIDATION
|
||||||
|
from app.schemas.drift import DriftReportResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/drift",
|
||||||
|
response_model=list[DriftReportResponse],
|
||||||
|
summary="Dernier rapport de dérive par site, plus la ligne globale",
|
||||||
|
responses=REPONSE_VALIDATION,
|
||||||
|
)
|
||||||
|
async def get_drift(
|
||||||
|
_: OperateurDep, service: DriftServiceDep, site_id: str | None = None
|
||||||
|
) -> list[DriftReportResponse]:
|
||||||
|
rapports = await service.derniers(site_id=site_id)
|
||||||
|
return [DriftReportResponse.model_validate(rapport) for rapport in rapports]
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
from app.api.openapi import (
|
||||||
|
REPONSE_SERVEUR,
|
||||||
|
REPONSES_ADMIN,
|
||||||
|
REPONSES_LECTEUR,
|
||||||
|
REPONSES_OPERATEUR,
|
||||||
|
)
|
||||||
from app.api.v1.endpoints import (
|
from app.api.v1.endpoints import (
|
||||||
alerts,
|
alerts,
|
||||||
auth,
|
auth,
|
||||||
health,
|
health,
|
||||||
|
monitoring,
|
||||||
predictions,
|
predictions,
|
||||||
readings,
|
readings,
|
||||||
recommendations,
|
recommendations,
|
||||||
@@ -38,3 +44,6 @@ api_router.include_router(
|
|||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
|
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
|
||||||
)
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
monitoring.router, prefix="/monitoring", tags=["monitoring"], responses=REPONSES_OPERATEUR
|
||||||
|
)
|
||||||
|
|||||||
@@ -45,15 +45,19 @@ 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()
|
||||||
|
|
||||||
if settings.mock_api_username is None or settings.mock_api_password is None:
|
username = settings.mock_api_username
|
||||||
|
password = (
|
||||||
|
settings.mock_api_password.get_secret_value()
|
||||||
|
if settings.mock_api_password is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not username or not username.strip() or not password or not password.strip():
|
||||||
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
|
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
|
||||||
|
|
||||||
return httpx.AsyncClient(
|
return httpx.AsyncClient(
|
||||||
base_url=settings.mock_api_base_url.rstrip("/"),
|
base_url=settings.mock_api_base_url.rstrip("/"),
|
||||||
auth=(
|
auth=(username, password),
|
||||||
settings.mock_api_username,
|
|
||||||
settings.mock_api_password.get_secret_value(),
|
|
||||||
),
|
|
||||||
timeout=settings.mock_api_timeout_seconds,
|
timeout=settings.mock_api_timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
# --autogenerate`, qui générerait alors un drop de sa table.
|
# --autogenerate`, qui générerait alors un drop de sa table.
|
||||||
|
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
|
from app.models.energy import (
|
||||||
|
Alert,
|
||||||
|
Dataset,
|
||||||
|
DriftReport,
|
||||||
|
Prediction,
|
||||||
|
Reading,
|
||||||
|
Recommendation,
|
||||||
|
Site,
|
||||||
|
)
|
||||||
from app.models.login_attempt import LoginAttempt
|
from app.models.login_attempt import LoginAttempt
|
||||||
from app.models.password_reset_attempt import PasswordResetAttempt
|
from app.models.password_reset_attempt import PasswordResetAttempt
|
||||||
from app.models.password_reset_token import PasswordResetToken
|
from app.models.password_reset_token import PasswordResetToken
|
||||||
@@ -14,6 +22,7 @@ __all__ = [
|
|||||||
"AppUser",
|
"AppUser",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
"Dataset",
|
"Dataset",
|
||||||
|
"DriftReport",
|
||||||
"LoginAttempt",
|
"LoginAttempt",
|
||||||
"PasswordResetAttempt",
|
"PasswordResetAttempt",
|
||||||
"PasswordResetToken",
|
"PasswordResetToken",
|
||||||
|
|||||||
@@ -208,3 +208,49 @@ class Recommendation(Base):
|
|||||||
explanation: Mapped[str] = mapped_column(Text)
|
explanation: Mapped[str] = mapped_column(Text)
|
||||||
rule_reference: Mapped[str] = mapped_column(Text)
|
rule_reference: Mapped[str] = mapped_column(Text)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class DriftReport(Base):
|
||||||
|
__tablename__ = "drift_report"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('stable', 'derive', 'indetermine')", name="ck_drift_report_status"
|
||||||
|
),
|
||||||
|
CheckConstraint("status = 'stable' OR reason IS NOT NULL", name="ck_drift_report_reason"),
|
||||||
|
CheckConstraint("n_observations >= 0", name="ck_drift_report_observations"),
|
||||||
|
Index("ix_drift_report_site_computed", "site_id", "computed_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
drift_report_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
computed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
# `NULL` porte la ligne globale, tous sites confondus : une derive d'ensemble et la derive
|
||||||
|
# d'un seul site ne se lisent pas dans le meme chiffre.
|
||||||
|
site_id: Mapped[str | None] = mapped_column(
|
||||||
|
Text, ForeignKey("site.site_id", name="fk_drift_report_site", ondelete="RESTRICT")
|
||||||
|
)
|
||||||
|
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
reference_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
reference_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
n_observations: Mapped[int] = mapped_column(Integer)
|
||||||
|
mae: Mapped[float | None] = mapped_column(Double)
|
||||||
|
mape: Mapped[float | None] = mapped_column(Double)
|
||||||
|
bias: Mapped[float | None] = mapped_column(Double)
|
||||||
|
reference_mae: Mapped[float | None] = mapped_column(Double)
|
||||||
|
coverage_ratio: Mapped[float | None] = mapped_column(Double)
|
||||||
|
insufficient_data_ratio: Mapped[float | None] = mapped_column(Double)
|
||||||
|
model_references: Mapped[list[str]] = mapped_column(ARRAY(Text))
|
||||||
|
status: Mapped[str] = mapped_column(Text)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
# Piège : une `UniqueConstraint` ne dédoublonnerait pas les lignes globales, dont `site_id` est
|
||||||
|
# NULL et qu'aucune n'est égale à une autre. Même forme que `uq_reading_source`.
|
||||||
|
Index(
|
||||||
|
"uq_drift_report_window",
|
||||||
|
DriftReport.window_end,
|
||||||
|
func.coalesce(DriftReport.site_id, text("''")),
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Surveillance de dérive du modèle de prévision (EC06, issue #45) : même gabarit que
|
||||||
|
# `app.detection.internal_alerts`, ordonnancé par le DAG `derive`.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.db.session import get_session_factory
|
||||||
|
from app.repositories.drift import DriftRepository, NouveauRapportDerive
|
||||||
|
from app.services.drift import STATUT_DERIVE, DriftService, Seuils
|
||||||
|
|
||||||
|
|
||||||
|
async def run_drift(
|
||||||
|
*, now: datetime | None = None, site_id: str | None = None, seuils: Seuils | None = None
|
||||||
|
) -> list[NouveauRapportDerive]:
|
||||||
|
"""Calcule les rapports de la fenêtre et les enregistre. Rend ce qui a été calculé, que la
|
||||||
|
ligne ait été écrite ou ignorée par l'index d'idempotence."""
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
depot = DriftRepository(session)
|
||||||
|
rapports = await DriftService(depot, seuils=seuils).evaluate(now=now, site_id=site_id)
|
||||||
|
await depot.enregistre(rapports)
|
||||||
|
await session.commit()
|
||||||
|
return rapports
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_instant(valeur: str) -> datetime:
|
||||||
|
instant = datetime.fromisoformat(valeur)
|
||||||
|
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
defauts = Seuils()
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="python -m app.monitoring.drift",
|
||||||
|
description="Surveillance de dérive du modèle de prévision EnerVision",
|
||||||
|
)
|
||||||
|
parser.add_argument("--site-id", default=None, help="Limite le calcul à un seul site.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--now",
|
||||||
|
type=_parse_instant,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Instant de référence (ISO 8601, UTC si le fuseau est omis). Défaut : l'heure courante."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--window-hours",
|
||||||
|
type=int,
|
||||||
|
default=int(defauts.fenetre.total_seconds() // 3600),
|
||||||
|
help="Durée de la fenêtre récente, et de la fenêtre de référence qui la précède.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--grace-hours",
|
||||||
|
type=int,
|
||||||
|
default=int(defauts.grace.total_seconds() // 3600),
|
||||||
|
help="Délai laissé à l'ingestion avant qu'une prévision soit jugée vérifiable.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-observations",
|
||||||
|
type=int,
|
||||||
|
default=defauts.min_observations,
|
||||||
|
help="En deçà, le verdict est `indetermine` plutôt qu'un chiffre trompeur.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--bias-threshold",
|
||||||
|
type=float,
|
||||||
|
default=defauts.seuil_biais,
|
||||||
|
help=(
|
||||||
|
"Biais absolu en kWh au-delà duquel le verdict bascule en dérive. "
|
||||||
|
"Zéro, le défaut, laisse le biais informatif : voir l'ADR 0013."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fail-on-drift",
|
||||||
|
action="store_true",
|
||||||
|
help="Sort en code non nul si une dérive est constatée, pour que la tâche rougisse.",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def seuils_depuis(args: argparse.Namespace) -> Seuils:
|
||||||
|
return Seuils(
|
||||||
|
fenetre=timedelta(hours=args.window_hours),
|
||||||
|
grace=timedelta(hours=args.grace_hours),
|
||||||
|
min_observations=args.min_observations,
|
||||||
|
seuil_biais=args.bias_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
# Échoue tôt si `APP_SECRET_KEY`/`DATABASE_URL` manquent, avant toute requête à la base.
|
||||||
|
get_settings()
|
||||||
|
rapports = asyncio.run(
|
||||||
|
run_drift(now=args.now, site_id=args.site_id, seuils=seuils_depuis(args))
|
||||||
|
)
|
||||||
|
|
||||||
|
for rapport in rapports:
|
||||||
|
cible = rapport.site_id or "TOUS SITES"
|
||||||
|
mae = f"{rapport.mae:.2f}" if rapport.mae is not None else "-"
|
||||||
|
print(
|
||||||
|
f"{cible} : {rapport.status}, MAE {mae} kWh sur {rapport.n_observations} prévision(s)"
|
||||||
|
f"{' : ' + rapport.reason if rapport.reason else ''}"
|
||||||
|
)
|
||||||
|
|
||||||
|
derive = any(rapport.status == STATUT_DERIVE for rapport in rapports)
|
||||||
|
return 1 if derive and args.fail_on_drift else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
sys.exit(main())
|
||||||
@@ -6,6 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.models.energy import Alert
|
from app.models.energy import Alert
|
||||||
|
|
||||||
|
# Douze colonnes par alerte, contre quatre pour une recommandation : le plafond asyncpg de
|
||||||
|
# 32 767 parametres tombe a 2 730 lignes, d'ou un lot plus petit que `recommendation.py`.
|
||||||
|
TAILLE_DE_LOT = 1000
|
||||||
|
|
||||||
|
|
||||||
class AlertRepository:
|
class AlertRepository:
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
@@ -44,12 +48,17 @@ class AlertRepository:
|
|||||||
}
|
}
|
||||||
for alerte in alerts
|
for alerte in alerts
|
||||||
]
|
]
|
||||||
requete = (
|
creees: list[Alert] = []
|
||||||
insert(Alert)
|
# Piège : asyncpg plafonne une requête à 32 767 paramètres. Une détection sur une fenêtre
|
||||||
.values(valeurs)
|
# chargée dépasse ce seuil, et l'`INSERT` d'un seul tenant échouerait.
|
||||||
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
|
for debut in range(0, len(valeurs), TAILLE_DE_LOT):
|
||||||
.returning(Alert)
|
requete = (
|
||||||
)
|
insert(Alert)
|
||||||
resultat = await self._session.execute(requete)
|
.values(valeurs[debut : debut + TAILLE_DE_LOT])
|
||||||
|
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
|
||||||
|
.returning(Alert)
|
||||||
|
)
|
||||||
|
resultat = await self._session.execute(requete)
|
||||||
|
creees.extend(resultat.scalars().all())
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
return resultat.scalars().all()
|
return creees
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Piège : deux dédoublonnages, pas un - DriftRepository.paires()
|
||||||
|
|
||||||
|
`prediction` n'a pas d'unicité sur `(site_id, target_at)` : chaque run de scoring empile une
|
||||||
|
ligne de plus. `uq_reading_source` autorise de son côté deux lectures au même instant quand la
|
||||||
|
`source` diffère. Joindre les deux tables sans `DISTINCT ON` des deux côtés compterait donc la
|
||||||
|
même heure plusieurs fois, et la moyenne d'erreur pèserait ces sites en double.
|
||||||
|
|
||||||
|
On retient la prédiction du run le plus récent, celle que sert `GET /api/v1/predictions`, avec
|
||||||
|
`prediction_id` en départage : `created_at` vaut l'heure de début de transaction et ne
|
||||||
|
distingue pas deux lignes du même run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Subquery, func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.energy import DriftReport, Prediction, Reading
|
||||||
|
|
||||||
|
TARGET_METRIC = "consumption_kwh"
|
||||||
|
STATUT_DISPONIBLE = "available"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PaireDerive:
|
||||||
|
site_id: str
|
||||||
|
target_at: datetime
|
||||||
|
predicted_value: float
|
||||||
|
actual_value: float
|
||||||
|
model_reference: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NouveauRapportDerive:
|
||||||
|
site_id: str | None
|
||||||
|
window_start: datetime
|
||||||
|
window_end: datetime
|
||||||
|
reference_start: datetime | None
|
||||||
|
reference_end: datetime | None
|
||||||
|
n_observations: int
|
||||||
|
mae: float | None
|
||||||
|
mape: float | None
|
||||||
|
bias: float | None
|
||||||
|
reference_mae: float | None
|
||||||
|
coverage_ratio: float | None
|
||||||
|
insufficient_data_ratio: float | None
|
||||||
|
model_references: list[str]
|
||||||
|
status: str
|
||||||
|
reason: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ComptageStatut:
|
||||||
|
site_id: str
|
||||||
|
status: str
|
||||||
|
nombre: int
|
||||||
|
|
||||||
|
|
||||||
|
def _predictions_retenues(*, debut: datetime, fin: datetime, site_id: str | None) -> Subquery:
|
||||||
|
requete = (
|
||||||
|
select(
|
||||||
|
Prediction.site_id,
|
||||||
|
Prediction.target_at,
|
||||||
|
Prediction.predicted_value,
|
||||||
|
Prediction.model_reference,
|
||||||
|
Prediction.status,
|
||||||
|
)
|
||||||
|
.distinct(Prediction.site_id, Prediction.target_at)
|
||||||
|
.where(
|
||||||
|
Prediction.target_metric == TARGET_METRIC,
|
||||||
|
Prediction.target_at >= debut,
|
||||||
|
Prediction.target_at < fin,
|
||||||
|
)
|
||||||
|
.order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id.desc())
|
||||||
|
)
|
||||||
|
if site_id is not None:
|
||||||
|
requete = requete.where(Prediction.site_id == site_id)
|
||||||
|
return requete.subquery()
|
||||||
|
|
||||||
|
|
||||||
|
def _lectures_retenues(*, debut: datetime, fin: datetime, site_id: str | None) -> Subquery:
|
||||||
|
requete = (
|
||||||
|
select(Reading.site_id, Reading.timestamp, Reading.consumption_kwh)
|
||||||
|
.distinct(Reading.site_id, Reading.timestamp)
|
||||||
|
.where(
|
||||||
|
Reading.timestamp >= debut,
|
||||||
|
Reading.timestamp < fin,
|
||||||
|
Reading.consumption_kwh.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(Reading.site_id, Reading.timestamp, Reading.reading_id.desc())
|
||||||
|
)
|
||||||
|
if site_id is not None:
|
||||||
|
requete = requete.where(Reading.site_id == site_id)
|
||||||
|
return requete.subquery()
|
||||||
|
|
||||||
|
|
||||||
|
class DriftRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def paires(
|
||||||
|
self, *, debut: datetime, fin: datetime, site_id: str | None = None
|
||||||
|
) -> Sequence[PaireDerive]:
|
||||||
|
predictions = _predictions_retenues(debut=debut, fin=fin, site_id=site_id)
|
||||||
|
lectures = _lectures_retenues(debut=debut, fin=fin, site_id=site_id)
|
||||||
|
requete = (
|
||||||
|
select(
|
||||||
|
predictions.c.site_id,
|
||||||
|
predictions.c.target_at,
|
||||||
|
predictions.c.predicted_value,
|
||||||
|
lectures.c.consumption_kwh,
|
||||||
|
predictions.c.model_reference,
|
||||||
|
)
|
||||||
|
.select_from(predictions)
|
||||||
|
.join(
|
||||||
|
lectures,
|
||||||
|
(lectures.c.site_id == predictions.c.site_id)
|
||||||
|
& (lectures.c.timestamp == predictions.c.target_at),
|
||||||
|
)
|
||||||
|
.where(predictions.c.status == STATUT_DISPONIBLE)
|
||||||
|
.order_by(predictions.c.site_id, predictions.c.target_at)
|
||||||
|
)
|
||||||
|
|
||||||
|
lignes = await self._session.execute(requete)
|
||||||
|
return [
|
||||||
|
PaireDerive(
|
||||||
|
site_id=ligne[0],
|
||||||
|
target_at=ligne[1],
|
||||||
|
predicted_value=ligne[2],
|
||||||
|
actual_value=ligne[3],
|
||||||
|
model_reference=ligne[4],
|
||||||
|
)
|
||||||
|
for ligne in lignes
|
||||||
|
]
|
||||||
|
|
||||||
|
async def comptages(
|
||||||
|
self, *, debut: datetime, fin: datetime, site_id: str | None = None
|
||||||
|
) -> Sequence[ComptageStatut]:
|
||||||
|
predictions = _predictions_retenues(debut=debut, fin=fin, site_id=site_id)
|
||||||
|
requete = (
|
||||||
|
select(predictions.c.site_id, predictions.c.status, func.count())
|
||||||
|
.select_from(predictions)
|
||||||
|
.group_by(predictions.c.site_id, predictions.c.status)
|
||||||
|
)
|
||||||
|
|
||||||
|
lignes = await self._session.execute(requete)
|
||||||
|
return [
|
||||||
|
ComptageStatut(site_id=ligne[0], status=ligne[1], nombre=ligne[2]) for ligne in lignes
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pourquoi : l'idempotence est déléguée à `uq_drift_report_window` plutôt qu'à une lecture
|
||||||
|
# préalable, comme pour les recommandations. Rejouer la commande sur la même fenêtre ne
|
||||||
|
# duplique donc rien.
|
||||||
|
async def enregistre(self, rapports: Sequence[NouveauRapportDerive]) -> int:
|
||||||
|
if not rapports:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
valeurs = [asdict(rapport) for rapport in rapports]
|
||||||
|
requete = (
|
||||||
|
insert(DriftReport)
|
||||||
|
.values(valeurs)
|
||||||
|
.on_conflict_do_nothing(
|
||||||
|
index_elements=[DriftReport.window_end, func.coalesce(DriftReport.site_id, "")]
|
||||||
|
)
|
||||||
|
.returning(DriftReport.drift_report_id)
|
||||||
|
)
|
||||||
|
return len((await self._session.scalars(requete)).all())
|
||||||
|
|
||||||
|
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
|
||||||
|
requete = (
|
||||||
|
select(DriftReport)
|
||||||
|
.distinct(DriftReport.site_id)
|
||||||
|
.order_by(
|
||||||
|
DriftReport.site_id,
|
||||||
|
DriftReport.computed_at.desc(),
|
||||||
|
DriftReport.drift_report_id.desc(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if site_id is not None:
|
||||||
|
requete = requete.where(DriftReport.site_id == site_id)
|
||||||
|
return (await self._session.scalars(requete)).all()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class DriftStatus(StrEnum):
|
||||||
|
STABLE = "stable"
|
||||||
|
DERIVE = "derive"
|
||||||
|
INDETERMINE = "indetermine"
|
||||||
|
|
||||||
|
|
||||||
|
class DriftReportResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
site_id: str | None
|
||||||
|
computed_at: datetime
|
||||||
|
window_start: datetime
|
||||||
|
window_end: datetime
|
||||||
|
reference_start: datetime | None
|
||||||
|
reference_end: datetime | None
|
||||||
|
n_observations: int
|
||||||
|
mae: float | None
|
||||||
|
mape: float | None
|
||||||
|
bias: float | None
|
||||||
|
reference_mae: float | None
|
||||||
|
coverage_ratio: float | None
|
||||||
|
insufficient_data_ratio: float | None
|
||||||
|
model_references: list[str]
|
||||||
|
status: DriftStatus
|
||||||
|
reason: str | None
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Contrainte : la dérive se mesure sur ce qui a déjà eu lieu - DriftService.evaluate()
|
||||||
|
|
||||||
|
Une prévision ne devient vérifiable que quand la lecture de son instant cible est ingérée. La
|
||||||
|
fenêtre est donc fermée à droite par un délai de grâce : sans lui, la dernière heure ferait
|
||||||
|
chuter le taux de couverture à chaque exécution, et le verdict dirait « dérive » alors que
|
||||||
|
seule l'ingestion n'avait pas fini son tour.
|
||||||
|
|
||||||
|
La comparaison se fait entre deux fenêtres vives de même durée, pas contre la métrique de
|
||||||
|
référence du modèle journalisée à l'entraînement. Ce ne sont pas les mêmes grandeurs :
|
||||||
|
l'entraînement mesure un backtest où la météo de l'heure cible est connue, le scoring prévoit
|
||||||
|
une heure future dont la météo ne l'est pas. Les comparer classerait le modèle « en dérive »
|
||||||
|
dès le premier jour, ce qui ne prouverait rien.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from app.models.energy import DriftReport
|
||||||
|
from app.repositories.drift import (
|
||||||
|
ComptageStatut,
|
||||||
|
DriftRepository,
|
||||||
|
NouveauRapportDerive,
|
||||||
|
PaireDerive,
|
||||||
|
)
|
||||||
|
|
||||||
|
STATUT_STABLE = "stable"
|
||||||
|
STATUT_DERIVE = "derive"
|
||||||
|
STATUT_INDETERMINE = "indetermine"
|
||||||
|
|
||||||
|
STATUT_INSUFFISANT = "insufficient_data"
|
||||||
|
STATUT_DISPONIBLE = "available"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Seuils:
|
||||||
|
# 168 h, la saisonnalité hebdomadaire que le modèle apprend par son lag principal : une
|
||||||
|
# fenêtre plus courte comparerait un week-end à une semaine ouvrée.
|
||||||
|
fenetre: timedelta = timedelta(hours=168)
|
||||||
|
grace: timedelta = timedelta(hours=2)
|
||||||
|
min_observations: int = 24
|
||||||
|
ratio_derive: float = 1.25
|
||||||
|
mae_plancher: float = 0.0
|
||||||
|
# Un biais se compte en kWh, donc ne se transpose pas d'un site à l'autre : zéro le désactive,
|
||||||
|
# sans cesser de le mesurer. Réglé par `--bias-threshold`, arbitrage dans l'ADR 0013.
|
||||||
|
seuil_biais: float = 0.0
|
||||||
|
seuil_couverture: float = 0.8
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Metriques:
|
||||||
|
n_observations: int
|
||||||
|
mae: float | None
|
||||||
|
mape: float | None
|
||||||
|
bias: float | None
|
||||||
|
model_references: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def mesure(paires: Sequence[PaireDerive]) -> Metriques:
|
||||||
|
if not paires:
|
||||||
|
return Metriques(n_observations=0, mae=None, mape=None, bias=None, model_references=[])
|
||||||
|
|
||||||
|
ecarts = [paire.predicted_value - paire.actual_value for paire in paires]
|
||||||
|
# Le MAPE diverge sur une consommation nulle : les sites à l'arrêt sortent de ce seul
|
||||||
|
# rapport, jamais des autres métriques.
|
||||||
|
ratios = [
|
||||||
|
abs(ecart / paire.actual_value)
|
||||||
|
for ecart, paire in zip(ecarts, paires, strict=True)
|
||||||
|
if paire.actual_value != 0
|
||||||
|
]
|
||||||
|
|
||||||
|
return Metriques(
|
||||||
|
n_observations=len(paires),
|
||||||
|
mae=sum(abs(ecart) for ecart in ecarts) / len(ecarts),
|
||||||
|
mape=(sum(ratios) / len(ratios) * 100) if ratios else None,
|
||||||
|
bias=sum(ecarts) / len(ecarts),
|
||||||
|
model_references=sorted({paire.model_reference for paire in paires}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Verdict:
|
||||||
|
status: str
|
||||||
|
reason: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class DriftService:
|
||||||
|
def __init__(self, depot: DriftRepository, *, seuils: Seuils | None = None) -> None:
|
||||||
|
self._depot = depot
|
||||||
|
self._seuils = seuils or Seuils()
|
||||||
|
|
||||||
|
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
|
||||||
|
"""Ce que sert l'API : le dernier rapport de chaque site, plus la ligne globale."""
|
||||||
|
return await self._depot.derniers(site_id=site_id)
|
||||||
|
|
||||||
|
async def evaluate(
|
||||||
|
self, *, now: datetime | None = None, site_id: str | None = None
|
||||||
|
) -> list[NouveauRapportDerive]:
|
||||||
|
"""Une ligne par site, plus une ligne globale dont le `site_id` est nul."""
|
||||||
|
fin = (now or datetime.now(UTC)) - self._seuils.grace
|
||||||
|
debut = fin - self._seuils.fenetre
|
||||||
|
reference_fin = debut
|
||||||
|
reference_debut = reference_fin - self._seuils.fenetre
|
||||||
|
|
||||||
|
recentes = await self._depot.paires(debut=debut, fin=fin, site_id=site_id)
|
||||||
|
anciennes = await self._depot.paires(
|
||||||
|
debut=reference_debut, fin=reference_fin, site_id=site_id
|
||||||
|
)
|
||||||
|
comptages = await self._depot.comptages(debut=debut, fin=fin, site_id=site_id)
|
||||||
|
|
||||||
|
gabarit = NouveauRapportDerive(
|
||||||
|
site_id=None,
|
||||||
|
window_start=debut,
|
||||||
|
window_end=fin,
|
||||||
|
reference_start=reference_debut,
|
||||||
|
reference_end=reference_fin,
|
||||||
|
n_observations=0,
|
||||||
|
mae=None,
|
||||||
|
mape=None,
|
||||||
|
bias=None,
|
||||||
|
reference_mae=None,
|
||||||
|
coverage_ratio=None,
|
||||||
|
insufficient_data_ratio=None,
|
||||||
|
model_references=[],
|
||||||
|
status=STATUT_INDETERMINE,
|
||||||
|
reason=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = [
|
||||||
|
self._rapport(
|
||||||
|
gabarit,
|
||||||
|
site=site,
|
||||||
|
recentes=[p for p in recentes if p.site_id == site],
|
||||||
|
anciennes=[p for p in anciennes if p.site_id == site],
|
||||||
|
comptages=[c for c in comptages if c.site_id == site],
|
||||||
|
)
|
||||||
|
for site in sorted(
|
||||||
|
{paire.site_id for paire in recentes} | {c.site_id for c in comptages}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
rapports.append(
|
||||||
|
self._rapport(
|
||||||
|
gabarit, site=None, recentes=recentes, anciennes=anciennes, comptages=comptages
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rapports
|
||||||
|
|
||||||
|
def _rapport(
|
||||||
|
self,
|
||||||
|
gabarit: NouveauRapportDerive,
|
||||||
|
*,
|
||||||
|
site: str | None,
|
||||||
|
recentes: Sequence[PaireDerive],
|
||||||
|
anciennes: Sequence[PaireDerive],
|
||||||
|
comptages: Sequence[ComptageStatut],
|
||||||
|
) -> NouveauRapportDerive:
|
||||||
|
metriques = mesure(recentes)
|
||||||
|
reference = mesure(anciennes)
|
||||||
|
couverture = _couverture(len(recentes), comptages)
|
||||||
|
verdict = self._verdict(metriques, reference_mae=reference.mae, couverture=couverture)
|
||||||
|
|
||||||
|
return replace(
|
||||||
|
gabarit,
|
||||||
|
site_id=site,
|
||||||
|
n_observations=metriques.n_observations,
|
||||||
|
mae=metriques.mae,
|
||||||
|
mape=metriques.mape,
|
||||||
|
bias=metriques.bias,
|
||||||
|
reference_mae=reference.mae,
|
||||||
|
coverage_ratio=couverture,
|
||||||
|
insufficient_data_ratio=_part_insuffisante(comptages),
|
||||||
|
model_references=metriques.model_references,
|
||||||
|
status=verdict.status,
|
||||||
|
reason=verdict.reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _verdict(
|
||||||
|
self, metriques: Metriques, *, reference_mae: float | None, couverture: float | None
|
||||||
|
) -> Verdict:
|
||||||
|
seuils = self._seuils
|
||||||
|
if metriques.n_observations < seuils.min_observations:
|
||||||
|
return Verdict(
|
||||||
|
STATUT_INDETERMINE,
|
||||||
|
f"{metriques.n_observations} prévision(s) vérifiée(s) sur la fenêtre, "
|
||||||
|
f"minimum {seuils.min_observations}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if couverture is not None and couverture < seuils.seuil_couverture:
|
||||||
|
return Verdict(
|
||||||
|
STATUT_DERIVE,
|
||||||
|
f"Couverture de {couverture:.0%}, sous le seuil de {seuils.seuil_couverture:.0%} : "
|
||||||
|
"le pipeline, pas le modèle.",
|
||||||
|
)
|
||||||
|
|
||||||
|
plafond = _plafond(reference_mae, ratio=seuils.ratio_derive, plancher=seuils.mae_plancher)
|
||||||
|
if metriques.mae is not None and plafond is not None and metriques.mae > plafond:
|
||||||
|
return Verdict(
|
||||||
|
STATUT_DERIVE,
|
||||||
|
f"MAE de {metriques.mae:.2f} kWh au-delà de {plafond:.2f} kWh, "
|
||||||
|
"seuil dérivé de la fenêtre de référence.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
seuils.seuil_biais > 0
|
||||||
|
and metriques.bias is not None
|
||||||
|
and abs(metriques.bias) > seuils.seuil_biais
|
||||||
|
):
|
||||||
|
return Verdict(
|
||||||
|
STATUT_DERIVE,
|
||||||
|
f"Biais de {metriques.bias:+.2f} kWh : le modèle se trompe toujours du même côté.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Verdict(STATUT_STABLE, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _plafond(reference_mae: float | None, *, ratio: float, plancher: float) -> float | None:
|
||||||
|
if reference_mae is None:
|
||||||
|
return plancher or None
|
||||||
|
return max(plancher, reference_mae * ratio)
|
||||||
|
|
||||||
|
|
||||||
|
def _couverture(apparie: int, comptages: Sequence[ComptageStatut]) -> float | None:
|
||||||
|
"""Part des prévisions disponibles qui ont trouvé leur réalisé. Mesure l'ingestion et
|
||||||
|
l'ordonnancement, pas la qualité du modèle."""
|
||||||
|
disponibles = sum(c.nombre for c in comptages if c.status == STATUT_DISPONIBLE)
|
||||||
|
return apparie / disponibles if disponibles else None
|
||||||
|
|
||||||
|
|
||||||
|
def _part_insuffisante(comptages: Sequence[ComptageStatut]) -> float | None:
|
||||||
|
total = sum(c.nombre for c in comptages)
|
||||||
|
if not total:
|
||||||
|
return None
|
||||||
|
return sum(c.nombre for c in comptages if c.status == STATUT_INSUFFISANT) / total
|
||||||
+288
-22
@@ -213,7 +213,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Cookie de rafraîchissement": []
|
"CookieRafraichissement": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Cookie de rafraîchissement": []
|
"CookieRafraichissement": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -423,7 +423,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -673,7 +673,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -757,7 +757,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -771,7 +771,7 @@
|
|||||||
"operationId": "update_user_api_v1_users__user_id__patch",
|
"operationId": "update_user_api_v1_users__user_id__patch",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -889,7 +889,7 @@
|
|||||||
"operationId": "reset_password_api_v1_users__user_id__password_reset_post",
|
"operationId": "reset_password_api_v1_users__user_id__password_reset_post",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1023,7 +1023,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1037,7 +1037,7 @@
|
|||||||
"operationId": "get_site_api_v1_sites__site_id__get",
|
"operationId": "get_site_api_v1_sites__site_id__get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1124,7 +1124,7 @@
|
|||||||
"operationId": "get_current_api_v1_sites__site_id__current_get",
|
"operationId": "get_current_api_v1_sites__site_id__current_get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1211,7 +1211,7 @@
|
|||||||
"operationId": "list_alerts_api_v1_alerts_get",
|
"operationId": "list_alerts_api_v1_alerts_get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1361,7 +1361,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1375,7 +1375,7 @@
|
|||||||
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
|
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1462,7 +1462,7 @@
|
|||||||
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
|
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1588,7 +1588,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1602,7 +1602,7 @@
|
|||||||
"operationId": "list_readings_api_v1_readings_get",
|
"operationId": "list_readings_api_v1_readings_get",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -1799,7 +1799,7 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1855,10 +1855,98 @@
|
|||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"Jeton d'accès": []
|
"JetonAcces": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/monitoring/drift": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"monitoring"
|
||||||
|
],
|
||||||
|
"summary": "Dernier rapport de dérive par site, plus la ligne globale",
|
||||||
|
"operationId": "get_drift_api_v1_monitoring_drift_get",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JetonAcces": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DriftReportResponse"
|
||||||
|
},
|
||||||
|
"title": "Response Get Drift Api V1 Monitoring Drift Get"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -1977,6 +2065,180 @@
|
|||||||
],
|
],
|
||||||
"title": "AlertType"
|
"title": "AlertType"
|
||||||
},
|
},
|
||||||
|
"DriftReportResponse": {
|
||||||
|
"properties": {
|
||||||
|
"site_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Site Id"
|
||||||
|
},
|
||||||
|
"computed_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Computed At"
|
||||||
|
},
|
||||||
|
"window_start": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Window Start"
|
||||||
|
},
|
||||||
|
"window_end": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Window End"
|
||||||
|
},
|
||||||
|
"reference_start": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Reference Start"
|
||||||
|
},
|
||||||
|
"reference_end": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Reference End"
|
||||||
|
},
|
||||||
|
"n_observations": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "N Observations"
|
||||||
|
},
|
||||||
|
"mae": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Mae"
|
||||||
|
},
|
||||||
|
"mape": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Mape"
|
||||||
|
},
|
||||||
|
"bias": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Bias"
|
||||||
|
},
|
||||||
|
"reference_mae": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Reference Mae"
|
||||||
|
},
|
||||||
|
"coverage_ratio": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Coverage Ratio"
|
||||||
|
},
|
||||||
|
"insufficient_data_ratio": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Insufficient Data Ratio"
|
||||||
|
},
|
||||||
|
"model_references": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Model References"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/components/schemas/DriftStatus"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Reason"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"site_id",
|
||||||
|
"computed_at",
|
||||||
|
"window_start",
|
||||||
|
"window_end",
|
||||||
|
"reference_start",
|
||||||
|
"reference_end",
|
||||||
|
"n_observations",
|
||||||
|
"mae",
|
||||||
|
"mape",
|
||||||
|
"bias",
|
||||||
|
"reference_mae",
|
||||||
|
"coverage_ratio",
|
||||||
|
"insufficient_data_ratio",
|
||||||
|
"model_references",
|
||||||
|
"status",
|
||||||
|
"reason"
|
||||||
|
],
|
||||||
|
"title": "DriftReportResponse"
|
||||||
|
},
|
||||||
|
"DriftStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"stable",
|
||||||
|
"derive",
|
||||||
|
"indetermine"
|
||||||
|
],
|
||||||
|
"title": "DriftStatus"
|
||||||
|
},
|
||||||
"ErrorResponse": {
|
"ErrorResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"detail": {
|
"detail": {
|
||||||
@@ -3225,13 +3487,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securitySchemes": {
|
"securitySchemes": {
|
||||||
"Cookie de rafraîchissement": {
|
"CookieRafraichissement": {
|
||||||
"type": "apiKey",
|
"type": "apiKey",
|
||||||
"description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.",
|
"description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.",
|
||||||
"in": "cookie",
|
"in": "cookie",
|
||||||
"name": "ev_refresh"
|
"name": "ev_refresh"
|
||||||
},
|
},
|
||||||
"Jeton d'accès": {
|
"JetonAcces": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"scheme": "bearer"
|
"scheme": "bearer"
|
||||||
}
|
}
|
||||||
@@ -3277,6 +3539,10 @@
|
|||||||
{
|
{
|
||||||
"name": "predictions",
|
"name": "predictions",
|
||||||
"description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
|
"description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "monitoring",
|
||||||
|
"description": "Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et les lectures réellement arrivées, par site et tous sites confondus. Réservé à partir du rôle `operateur`, qui agit sur un pipeline dégradé."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,11 @@ disallow_untyped_defs = false
|
|||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing"
|
addopts = "-q --strict-markers -m 'not integration and not chaine' --cov=app --cov-report=term-missing"
|
||||||
markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"]
|
markers = [
|
||||||
|
"integration: requiert une base PostgreSQL joignable, hors `make test`",
|
||||||
|
"chaine: requiert en plus l'environnement uv de ml/, hors `make test` et hors `-m integration`",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["app"]
|
source = ["app"]
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = {
|
|||||||
("GET", "/api/v1/readings"): Role.LECTEUR,
|
("GET", "/api/v1/readings"): Role.LECTEUR,
|
||||||
("GET", "/api/v1/predictions"): Role.LECTEUR,
|
("GET", "/api/v1/predictions"): Role.LECTEUR,
|
||||||
("GET", "/api/v1/sensors/status"): Role.ADMIN,
|
("GET", "/api/v1/sensors/status"): Role.ADMIN,
|
||||||
|
("GET", "/api/v1/monitoring/drift"): Role.OPERATEUR,
|
||||||
("GET", "/api/v1/users"): Role.ADMIN,
|
("GET", "/api/v1/users"): Role.ADMIN,
|
||||||
("POST", "/api/v1/users"): Role.ADMIN,
|
("POST", "/api/v1/users"): Role.ADMIN,
|
||||||
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
|
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Piège : ces fixtures valident leurs écritures, contrairement à celles de tests/repositories.
|
||||||
|
|
||||||
|
Un endpoint ouvre sa propre session par `get_session` : il ne verrait pas une ligne semée dans
|
||||||
|
une transaction en cours. Lui passer la session de la fixture par `dependency_overrides`
|
||||||
|
supprimerait justement ce que ces tests prouvent, et `RecommendationService.generate` valide de
|
||||||
|
toute façon lui-même. L'isolation vient donc de la marque portée par chaque `site_id`, et le
|
||||||
|
nettoyage est explicite, dans l'ordre imposé par les clés étrangères `RESTRICT`.
|
||||||
|
|
||||||
|
Contrainte : toutes ces fixtures sont à portée fonction. `engine_per_test` vide le cache du
|
||||||
|
moteur après chaque test ; une fixture de module verrait un moteur déjà fermé à son démontage,
|
||||||
|
et ses lignes resteraient en base.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator, Callable, Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_principal
|
||||||
|
from app.core.principal import Principal
|
||||||
|
from app.core.roles import AccountKind, Role
|
||||||
|
from app.db.session import get_session_factory
|
||||||
|
from app.models.energy import Alert, Prediction, Reading, Recommendation, Site
|
||||||
|
from tests.repositories.test_alert import creer_alerte
|
||||||
|
from tests.repositories.test_prediction import creer_prediction
|
||||||
|
from tests.repositories.test_reading import creer_lecture
|
||||||
|
from tests.repositories.test_site import creer as creer_site
|
||||||
|
|
||||||
|
INSTANT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class JeuMetier:
|
||||||
|
"""Identifiants seuls, jamais d'instance ORM : un attribut relu sur une session fermée
|
||||||
|
déclenche un `MissingGreenlet`."""
|
||||||
|
|
||||||
|
site_id: str
|
||||||
|
site_voisin: str
|
||||||
|
alert_id: int
|
||||||
|
prediction_id: int
|
||||||
|
instant: datetime
|
||||||
|
|
||||||
|
|
||||||
|
async def _supprime(session: AsyncSession, sites: list[str]) -> None:
|
||||||
|
# La suppression des recommandations est inconditionnelle : `POST /generate` en cree hors du
|
||||||
|
# controle de la fixture, et `alert` les retient par une cle etrangere `RESTRICT`.
|
||||||
|
alertes = select(Alert.alert_id).where(Alert.site_id.in_(sites))
|
||||||
|
await session.execute(delete(Recommendation).where(Recommendation.alert_id.in_(alertes)))
|
||||||
|
await session.execute(delete(Alert).where(Alert.site_id.in_(sites)))
|
||||||
|
await session.execute(delete(Prediction).where(Prediction.site_id.in_(sites)))
|
||||||
|
await session.execute(delete(Reading).where(Reading.site_id.in_(sites)))
|
||||||
|
await session.execute(delete(Site).where(Site.site_id.in_(sites)))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def marque() -> str:
|
||||||
|
return uuid4().hex[:12]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def jeu_metier(marque: str) -> AsyncIterator[JeuMetier]:
|
||||||
|
"""Un site instrumenté, un site voisin, trois lectures horaires, une prédiction, une alerte.
|
||||||
|
|
||||||
|
Le voisin existe pour que les tests de filtre prouvent qu'ils écartent quelque chose.
|
||||||
|
"""
|
||||||
|
site_id = f"SITE-{marque}"
|
||||||
|
voisin = f"SITE-{marque}-VOISIN"
|
||||||
|
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
await creer_site(session, site_id=site_id, capacity_kw=100.0)
|
||||||
|
await creer_site(session, site_id=voisin, capacity_kw=100.0)
|
||||||
|
for decalage in range(3):
|
||||||
|
await creer_lecture(
|
||||||
|
session,
|
||||||
|
site_id=site_id,
|
||||||
|
timestamp=INSTANT - timedelta(hours=decalage),
|
||||||
|
consumption_kw=10.0 + decalage,
|
||||||
|
)
|
||||||
|
prediction = await creer_prediction(session, site_id=site_id, target_at=INSTANT)
|
||||||
|
alerte = await creer_alerte(session, site_id=site_id, timestamp=INSTANT)
|
||||||
|
jeu = JeuMetier(
|
||||||
|
site_id=site_id,
|
||||||
|
site_voisin=voisin,
|
||||||
|
alert_id=alerte.alert_id,
|
||||||
|
prediction_id=prediction.prediction_id,
|
||||||
|
instant=INSTANT,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield jeu
|
||||||
|
finally:
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
await _supprime(session, [site_id, voisin])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def site_nu(marque: str) -> AsyncIterator[str]:
|
||||||
|
"""Un site sans lecture ni prédiction : le cas que seul un vrai `LEFT JOIN` distingue."""
|
||||||
|
site_id = f"SITE-{marque}-NU"
|
||||||
|
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
await creer_site(session, site_id=site_id, capacity_kw=100.0)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield site_id
|
||||||
|
finally:
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
await _supprime(session, [site_id])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def principal_injecte(app: FastAPI) -> Iterator[Callable[[Role], None]]:
|
||||||
|
def installe(role: Role = Role.LECTEUR) -> None:
|
||||||
|
app.dependency_overrides[get_current_principal] = lambda: Principal(
|
||||||
|
id=uuid4(),
|
||||||
|
email="parcours@enervision.fr",
|
||||||
|
role=role,
|
||||||
|
kind=AccountKind.HUMAIN,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield installe
|
||||||
|
app.dependency_overrides.pop(get_current_principal, None)
|
||||||
@@ -227,24 +227,26 @@ async def test_a_real_token_reaches_exactly_the_routes_of_its_rank(
|
|||||||
assert ecarts == []
|
assert ecarts == []
|
||||||
|
|
||||||
|
|
||||||
# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture
|
# Contrainte : les deux rangs ne se séparent que sur les routes que `ROLE_MINIMUM` réserve à
|
||||||
# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur
|
# `operateur`. Une route d'opérateur ajoutée sans être classée fait diverger les statuts sans
|
||||||
# arrive sans que `ROLE_MINIMUM` soit mis à jour.
|
# qu'aucune entrée ne l'annonce, et une garde d'opérateur posée par erreur sur une route de
|
||||||
|
# lecture fait diverger ce qui devait rester identique.
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank(
|
async def test_the_operator_rank_diverges_from_the_reader_rank_only_where_declared(
|
||||||
comptes_par_role: dict[Role, str], client: AsyncClient
|
comptes_par_role: dict[Role, str], client: AsyncClient
|
||||||
) -> None:
|
) -> None:
|
||||||
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
|
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
|
||||||
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
|
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
|
||||||
divergences: list[tuple[str, str]] = []
|
ecarts: list[tuple[str, str]] = []
|
||||||
|
|
||||||
for methode, chemin in ROLE_MINIMUM:
|
for (methode, chemin), minimum in ROLE_MINIMUM.items():
|
||||||
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
|
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
|
||||||
cote_operateur = await appelle(client, methode, chemin, headers=operateur)
|
cote_operateur = await appelle(client, methode, chemin, headers=operateur)
|
||||||
if cote_lecteur.status_code != cote_operateur.status_code:
|
diverge = cote_lecteur.status_code != cote_operateur.status_code
|
||||||
divergences.append((methode, chemin))
|
if diverge is not (minimum is Role.OPERATEUR):
|
||||||
|
ecarts.append((methode, chemin))
|
||||||
|
|
||||||
assert divergences == []
|
assert ecarts == []
|
||||||
|
|
||||||
|
|
||||||
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
|
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
from collections.abc import Iterator, Sequence
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.api.deps import get_current_principal, get_drift_service
|
||||||
|
from app.core.principal import Principal
|
||||||
|
from app.core.roles import AccountKind, Role
|
||||||
|
from app.models.energy import DriftReport
|
||||||
|
|
||||||
|
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def operateur() -> Principal:
|
||||||
|
return Principal(
|
||||||
|
id=uuid4(),
|
||||||
|
email="operateur@enervision.fr",
|
||||||
|
role=Role.OPERATEUR,
|
||||||
|
kind=AccountKind.HUMAIN,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rapport(*, site_id: str | None) -> DriftReport:
|
||||||
|
return DriftReport(
|
||||||
|
drift_report_id=1,
|
||||||
|
computed_at=INSTANT,
|
||||||
|
site_id=site_id,
|
||||||
|
window_start=INSTANT - timedelta(hours=168),
|
||||||
|
window_end=INSTANT,
|
||||||
|
reference_start=None,
|
||||||
|
reference_end=None,
|
||||||
|
n_observations=48,
|
||||||
|
mae=1.5,
|
||||||
|
mape=12.0,
|
||||||
|
bias=0.3,
|
||||||
|
reference_mae=1.2,
|
||||||
|
coverage_ratio=0.95,
|
||||||
|
insufficient_data_ratio=0.0,
|
||||||
|
model_references=["lightgbm-aaa"],
|
||||||
|
status="stable",
|
||||||
|
reason=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FauxService:
|
||||||
|
def __init__(self, rapports: Sequence[DriftReport]) -> None:
|
||||||
|
self.rapports = list(rapports)
|
||||||
|
self.site_demande: str | None = None
|
||||||
|
|
||||||
|
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
|
||||||
|
self.site_demande = site_id
|
||||||
|
return self.rapports
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def servi(app: FastAPI) -> Iterator[list[DriftReport]]:
|
||||||
|
rapports = [rapport(site_id="SITE001"), rapport(site_id=None)]
|
||||||
|
service = FauxService(rapports)
|
||||||
|
app.dependency_overrides[get_current_principal] = operateur
|
||||||
|
app.dependency_overrides[get_drift_service] = lambda: service
|
||||||
|
yield rapports
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_returns_the_latest_report_of_every_site(
|
||||||
|
servi: list[DriftReport], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
reponse = await client.get("/api/v1/monitoring/drift")
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
assert [ligne["site_id"] for ligne in reponse.json()] == ["SITE001", None]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_exposes_the_metrics_of_the_stored_report(
|
||||||
|
servi: list[DriftReport], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
reponse = await client.get("/api/v1/monitoring/drift")
|
||||||
|
|
||||||
|
premier = reponse.json()[0]
|
||||||
|
assert premier["status"] == "stable"
|
||||||
|
assert premier["mae"] == 1.5
|
||||||
|
assert premier["model_references"] == ["lightgbm-aaa"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_returns_an_empty_list_when_no_report_exists(
|
||||||
|
app: FastAPI, client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
app.dependency_overrides[get_current_principal] = operateur
|
||||||
|
app.dependency_overrides[get_drift_service] = lambda: FauxService([])
|
||||||
|
|
||||||
|
reponse = await client.get("/api/v1/monitoring/drift")
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
assert reponse.json() == []
|
||||||
|
app.dependency_overrides.clear()
|
||||||
@@ -104,8 +104,8 @@ def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> No
|
|||||||
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
|
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
|
||||||
schemes = schema["components"]["securitySchemes"]
|
schemes = schema["components"]["securitySchemes"]
|
||||||
|
|
||||||
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie"
|
assert schemes["CookieRafraichissement"]["in"] == "cookie"
|
||||||
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh"
|
assert schemes["CookieRafraichissement"]["name"] == "ev_refresh"
|
||||||
|
|
||||||
|
|
||||||
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
|
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.core.roles import Role
|
||||||
|
from tests.api.conftest import JeuMetier
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
async def genere(client: AsyncClient, site_id: str) -> dict[str, int]:
|
||||||
|
# Toujours borne a un site : sans `site_id`, le service examine toutes les alertes de la
|
||||||
|
# base, y compris celles d'un autre test, et le rapport cesse d'etre deterministe.
|
||||||
|
reponse = await client.post(f"/api/v1/recommendations/generate?site_id={site_id}")
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
return dict(reponse.json())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_creates_a_recommendation_for_the_alert_of_the_requested_site(
|
||||||
|
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.ADMIN)
|
||||||
|
|
||||||
|
rapport = await genere(client, jeu_metier.site_id)
|
||||||
|
|
||||||
|
assert rapport["alerts_examined"] == 1
|
||||||
|
assert rapport["recommendations_created"] >= 1
|
||||||
|
assert rapport["already_present"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_creates_nothing_more_when_it_runs_twice_on_the_same_alerts(
|
||||||
|
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.ADMIN)
|
||||||
|
premier = await genere(client, jeu_metier.site_id)
|
||||||
|
|
||||||
|
second = await genere(client, jeu_metier.site_id)
|
||||||
|
|
||||||
|
assert second["recommendations_created"] == 0
|
||||||
|
assert second["already_present"] == premier["recommendations_created"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generate_examines_no_alert_when_the_requested_site_has_none(
|
||||||
|
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.ADMIN)
|
||||||
|
|
||||||
|
rapport = await genere(client, jeu_metier.site_voisin)
|
||||||
|
|
||||||
|
assert rapport["alerts_examined"] == 0
|
||||||
|
assert rapport["recommendations_created"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_recommendations_returns_what_generate_persisted_in_another_session(
|
||||||
|
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.ADMIN)
|
||||||
|
await genere(client, jeu_metier.site_id)
|
||||||
|
|
||||||
|
reponse = await client.get("/api/v1/recommendations")
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
miennes = [r for r in reponse.json() if r["alert_id"] == jeu_metier.alert_id]
|
||||||
|
assert miennes != []
|
||||||
|
assert all(r["rule_reference"] for r in miennes)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_recommendation_returns_the_row_created_by_generate(
|
||||||
|
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.ADMIN)
|
||||||
|
await genere(client, jeu_metier.site_id)
|
||||||
|
liste = await client.get("/api/v1/recommendations")
|
||||||
|
creee = next(r for r in liste.json() if r["alert_id"] == jeu_metier.alert_id)
|
||||||
|
|
||||||
|
reponse = await client.get(f"/api/v1/recommendations/{creee['recommendation_id']}")
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
assert reponse.json() == creee
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_recommendation_returns_404_when_the_identifier_is_unknown(
|
||||||
|
principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.LECTEUR)
|
||||||
|
|
||||||
|
reponse = await client.get("/api/v1/recommendations/9999999")
|
||||||
|
|
||||||
|
assert reponse.status_code == 404
|
||||||
|
assert reponse.json()["detail"] == "Recommandation introuvable"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.core.roles import Role
|
||||||
|
from app.db.session import get_session_factory
|
||||||
|
from tests.api.conftest import JeuMetier
|
||||||
|
from tests.repositories.test_reading import creer_lecture
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_sites_returns_the_seeded_site_with_its_stored_attributes(
|
||||||
|
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.LECTEUR)
|
||||||
|
|
||||||
|
reponse = await client.get("/api/v1/sites")
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
mien = next(site for site in reponse.json() if site["site_id"] == jeu_metier.site_id)
|
||||||
|
assert mien["capacity_kw"] == 100.0
|
||||||
|
assert mien["site_name"] == "Site de test"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_site_returns_404_when_the_identifier_is_absent_from_the_database(
|
||||||
|
principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.LECTEUR)
|
||||||
|
|
||||||
|
reponse = await client.get("/api/v1/sites/SITE-JAMAIS-INSERE")
|
||||||
|
|
||||||
|
assert reponse.status_code == 404
|
||||||
|
assert reponse.json()["detail"] == "Site introuvable"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_current_returns_the_most_recent_reading_when_several_hours_are_stored(
|
||||||
|
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.LECTEUR)
|
||||||
|
|
||||||
|
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
corps = reponse.json()
|
||||||
|
assert corps["consumption_kw"] == 10.0
|
||||||
|
assert corps["timestamp"].startswith("2026-09-16T12:00")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_current_keeps_the_highest_reading_id_when_two_sources_share_the_timestamp(
|
||||||
|
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.LECTEUR)
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
await creer_lecture(
|
||||||
|
session,
|
||||||
|
site_id=jeu_metier.site_id,
|
||||||
|
timestamp=jeu_metier.instant,
|
||||||
|
source="api_history",
|
||||||
|
consumption_kw=999.0,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
|
||||||
|
|
||||||
|
assert reponse.json()["consumption_kw"] == 999.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_current_reports_a_critical_quality_when_the_site_has_no_reading(
|
||||||
|
site_nu: str, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
principal_injecte(Role.LECTEUR)
|
||||||
|
|
||||||
|
reponse = await client.get(f"/api/v1/sites/{site_nu}/current")
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
corps = reponse.json()
|
||||||
|
assert corps["timestamp"] is None
|
||||||
|
assert corps["data_quality"] == "critical"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -9,7 +9,15 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
|
from app.models.energy import (
|
||||||
|
Alert,
|
||||||
|
Dataset,
|
||||||
|
DriftReport,
|
||||||
|
Prediction,
|
||||||
|
Reading,
|
||||||
|
Recommendation,
|
||||||
|
Site,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||||
@@ -112,8 +120,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 +157,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 +177,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,21 +227,22 @@ async def test_alert_rejects_prediction_when_site_differs(
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
statement = insert(Alert).values(
|
||||||
|
source_alert_id=str(uuid4()),
|
||||||
|
site_id=other_site,
|
||||||
|
source="enervision",
|
||||||
|
timestamp=MOMENT,
|
||||||
|
type="spike",
|
||||||
|
severity="high",
|
||||||
|
message="Test",
|
||||||
|
prediction_id=prediction_id,
|
||||||
|
raw_data={},
|
||||||
|
)
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(
|
await data_connection.execute(statement)
|
||||||
insert(Alert).values(
|
|
||||||
source_alert_id=str(uuid4()),
|
|
||||||
site_id=other_site,
|
|
||||||
source="enervision",
|
|
||||||
timestamp=MOMENT,
|
|
||||||
type="spike",
|
|
||||||
severity="high",
|
|
||||||
message="Test",
|
|
||||||
prediction_id=prediction_id,
|
|
||||||
raw_data={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_recommendation_is_unique_when_alert_and_rule_match(
|
async def test_recommendation_is_unique_when_alert_and_rule_match(
|
||||||
@@ -256,6 +272,67 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
def _rapport(**remplacements: object) -> dict[str, object]:
|
||||||
|
defauts: dict[str, object] = {
|
||||||
|
"site_id": None,
|
||||||
|
"window_start": MOMENT,
|
||||||
|
"window_end": MOMENT,
|
||||||
|
"n_observations": 12,
|
||||||
|
"model_references": ["lightgbm-aaa"],
|
||||||
|
"status": "stable",
|
||||||
|
"reason": None,
|
||||||
|
}
|
||||||
|
return {**defauts, **remplacements}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_report_rejects_an_unknown_status(data_connection: AsyncConnection) -> None:
|
||||||
|
statement = insert(DriftReport).values(**_rapport(status="douteux", reason="x"))
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
async with savepoint:
|
||||||
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_report_rejects_a_drift_without_a_reason(
|
||||||
|
data_connection: AsyncConnection,
|
||||||
|
) -> None:
|
||||||
|
statement = insert(DriftReport).values(**_rapport(status="derive"))
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
async with savepoint:
|
||||||
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_report_accepts_one_global_row_without_a_site(
|
||||||
|
data_connection: AsyncConnection,
|
||||||
|
) -> None:
|
||||||
|
identifiant = (
|
||||||
|
await data_connection.execute(
|
||||||
|
insert(DriftReport).values(**_rapport()).returning(DriftReport.drift_report_id)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
assert identifiant is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_report_is_unique_when_window_and_site_match(
|
||||||
|
data_connection: AsyncConnection,
|
||||||
|
) -> None:
|
||||||
|
fenetre = MOMENT + timedelta(days=1)
|
||||||
|
statement = insert(DriftReport).values(**_rapport(window_end=fenetre))
|
||||||
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,41 @@ def test_create_mock_api_client_requires_credentials(
|
|||||||
mock_api_import.create_mock_api_client()
|
mock_api_import.create_mock_api_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("username", "password_value"),
|
||||||
|
[
|
||||||
|
("", "test-password"),
|
||||||
|
("test-user", ""),
|
||||||
|
(" ", "test-password"),
|
||||||
|
("test-user", " "),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_create_mock_api_client_rejects_empty_credentials(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
username: str,
|
||||||
|
password_value: str,
|
||||||
|
) -> None:
|
||||||
|
password = MagicMock()
|
||||||
|
password.get_secret_value.return_value = password_value
|
||||||
|
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
mock_api_username=username,
|
||||||
|
mock_api_password=password,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mock_api_import,
|
||||||
|
"get_settings",
|
||||||
|
lambda: settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="Les identifiants de l'API Mock ne sont pas configurés",
|
||||||
|
):
|
||||||
|
mock_api_import.create_mock_api_client()
|
||||||
|
|
||||||
|
|
||||||
async def test_create_mock_api_client_uses_configuration(
|
async def test_create_mock_api_client_uses_configuration(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import pytest
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.energy import Alert
|
from app.models.energy import Alert
|
||||||
|
from app.repositories import alert as module_alert
|
||||||
from app.repositories.alert import AlertRepository
|
from app.repositories.alert import AlertRepository
|
||||||
from app.schemas.alert import AlertSeverity
|
from app.schemas.alert import AlertSeverity
|
||||||
from tests.repositories.test_site import creer as creer_site
|
from tests.repositories.test_site import creer as creer_site
|
||||||
@@ -146,3 +147,20 @@ async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession)
|
|||||||
creees = await depot.create_many([])
|
creees = await depot.create_many([])
|
||||||
|
|
||||||
assert creees == []
|
assert creees == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_many_inserts_every_alert_across_several_batches(
|
||||||
|
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(module_alert, "TAILLE_DE_LOT", 2)
|
||||||
|
site = await creer_site(session)
|
||||||
|
depot = AlertRepository(session)
|
||||||
|
a_inserer = [
|
||||||
|
_alerte_a_inserer(site_id=site.site_id, source_alert_id=f"threshold:lot-{index}")
|
||||||
|
for index in range(5)
|
||||||
|
]
|
||||||
|
|
||||||
|
creees = await depot.create_many(a_inserer)
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert len(creees) == 5
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.sql import ClauseElement
|
||||||
|
|
||||||
|
from app.repositories.drift import (
|
||||||
|
DriftRepository,
|
||||||
|
NouveauRapportDerive,
|
||||||
|
_lectures_retenues,
|
||||||
|
_predictions_retenues,
|
||||||
|
)
|
||||||
|
from tests.repositories.test_prediction import creer_prediction
|
||||||
|
from tests.repositories.test_reading import creer_lecture
|
||||||
|
from tests.repositories.test_site import creer as creer_site
|
||||||
|
|
||||||
|
DEBUT = datetime(2026, 9, 15, tzinfo=UTC)
|
||||||
|
FIN = datetime(2026, 9, 22, tzinfo=UTC)
|
||||||
|
CIBLE = datetime(2026, 9, 16, 12, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def sql(requete: ClauseElement) -> str:
|
||||||
|
return str(requete.compile(dialect=postgresql.dialect())) # type: ignore[no-untyped-call]
|
||||||
|
|
||||||
|
|
||||||
|
def rapport(**remplacements: object) -> NouveauRapportDerive:
|
||||||
|
defauts: dict[str, object] = {
|
||||||
|
"site_id": None,
|
||||||
|
"window_start": DEBUT,
|
||||||
|
"window_end": FIN,
|
||||||
|
"reference_start": None,
|
||||||
|
"reference_end": None,
|
||||||
|
"n_observations": 10,
|
||||||
|
"mae": 1.0,
|
||||||
|
"mape": 5.0,
|
||||||
|
"bias": 0.1,
|
||||||
|
"reference_mae": None,
|
||||||
|
"coverage_ratio": 1.0,
|
||||||
|
"insufficient_data_ratio": 0.0,
|
||||||
|
"model_references": ["lightgbm-aaa"],
|
||||||
|
"status": "stable",
|
||||||
|
"reason": None,
|
||||||
|
}
|
||||||
|
return NouveauRapportDerive(**{**defauts, **remplacements}) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_predictions_keep_one_row_per_site_and_target_in_sql() -> None:
|
||||||
|
requete = sql(_predictions_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
|
||||||
|
|
||||||
|
assert "DISTINCT ON (prediction.site_id, prediction.target_at)" in requete
|
||||||
|
assert "prediction.prediction_id DESC" in requete
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_keep_one_row_per_site_and_instant_in_sql() -> None:
|
||||||
|
requete = sql(_lectures_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
|
||||||
|
|
||||||
|
assert "DISTINCT ON (reading.site_id, reading.timestamp)" in requete
|
||||||
|
assert "reading.reading_id DESC" in requete
|
||||||
|
|
||||||
|
|
||||||
|
def test_predictions_restrict_themselves_to_the_requested_site_in_sql() -> None:
|
||||||
|
requete = sql(_predictions_retenues(debut=DEBUT, fin=FIN, site_id="SITE001").element)
|
||||||
|
|
||||||
|
assert requete.count("prediction.site_id = ") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_ignore_a_missing_consumption_in_sql() -> None:
|
||||||
|
requete = sql(_lectures_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
|
||||||
|
|
||||||
|
assert "reading.consumption_kwh IS NOT NULL" in requete
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_repository_pairs_a_prediction_with_the_reading_of_the_same_instant(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
site = await creer_site(session)
|
||||||
|
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
|
||||||
|
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
|
||||||
|
|
||||||
|
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert [(p.predicted_value, p.actual_value) for p in paires] == [(12.0, 10.0)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_repository_keeps_the_latest_run_when_several_predictions_share_a_target(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
site = await creer_site(session)
|
||||||
|
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
|
||||||
|
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=99.0)
|
||||||
|
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
|
||||||
|
|
||||||
|
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert [p.predicted_value for p in paires] == [99.0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_repository_keeps_one_reading_per_instant_when_two_sources_wrote_the_same_hour(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
site = await creer_site(session)
|
||||||
|
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
|
||||||
|
await creer_lecture(
|
||||||
|
session, site_id=site.site_id, timestamp=CIBLE, source="api_current", consumption_kwh=10.0
|
||||||
|
)
|
||||||
|
await creer_lecture(
|
||||||
|
session, site_id=site.site_id, timestamp=CIBLE, source="api_history", consumption_kwh=20.0
|
||||||
|
)
|
||||||
|
|
||||||
|
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert [p.actual_value for p in paires] == [20.0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_repository_excludes_an_insufficient_data_prediction_from_the_pairs(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
site = await creer_site(session)
|
||||||
|
await creer_prediction(
|
||||||
|
session,
|
||||||
|
site_id=site.site_id,
|
||||||
|
target_at=CIBLE,
|
||||||
|
predicted_value=None,
|
||||||
|
status="insufficient_data",
|
||||||
|
failure_reason="historique trop court",
|
||||||
|
)
|
||||||
|
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
|
||||||
|
|
||||||
|
depot = DriftRepository(session)
|
||||||
|
paires = await depot.paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||||
|
comptages = await depot.comptages(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert paires == []
|
||||||
|
assert [(c.status, c.nombre) for c in comptages] == [("insufficient_data", 1)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_repository_excludes_a_target_outside_the_window(session: AsyncSession) -> None:
|
||||||
|
site = await creer_site(session)
|
||||||
|
hors_fenetre = FIN + timedelta(hours=1)
|
||||||
|
await creer_prediction(
|
||||||
|
session, site_id=site.site_id, target_at=hors_fenetre, predicted_value=12.0
|
||||||
|
)
|
||||||
|
await creer_lecture(session, site_id=site.site_id, timestamp=hors_fenetre, consumption_kwh=10.0)
|
||||||
|
|
||||||
|
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert paires == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_repository_reads_back_the_global_report_it_wrote(session: AsyncSession) -> None:
|
||||||
|
depot = DriftRepository(session)
|
||||||
|
fenetre = datetime(2035, 3, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
ecrites = await depot.enregistre([rapport(window_end=fenetre)])
|
||||||
|
derniers = await depot.derniers()
|
||||||
|
globaux = [r for r in derniers if r.site_id is None and r.window_end == fenetre]
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert ecrites == 1
|
||||||
|
assert len(globaux) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_repository_ignores_a_second_report_for_the_same_window_and_site(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
depot = DriftRepository(session)
|
||||||
|
fenetre = datetime(2035, 4, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
premiere = await depot.enregistre([rapport(window_end=fenetre)])
|
||||||
|
seconde = await depot.enregistre([rapport(window_end=fenetre, status="derive", reason="x")])
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert premiere == 1
|
||||||
|
assert seconde == 0
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ async def creer_lecture(session: AsyncSession, *, site_id: str, **overrides: obj
|
|||||||
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
|
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
|
||||||
source=overrides.get("source", "api_current"),
|
source=overrides.get("source", "api_current"),
|
||||||
consumption_kw=overrides.get("consumption_kw", 10.0),
|
consumption_kw=overrides.get("consumption_kw", 10.0),
|
||||||
|
# Nul par defaut : seules les mesures en kWh alimentent la comparaison prevu/realise, et
|
||||||
|
# un override silencieusement ignore laissait la colonne vide sans que rien ne le dise.
|
||||||
|
consumption_kwh=overrides.get("consumption_kwh"),
|
||||||
data_quality=overrides.get("data_quality", "good"),
|
data_quality=overrides.get("data_quality", "good"),
|
||||||
raw_data=overrides.get("raw_data", {}),
|
raw_data=overrides.get("raw_data", {}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
requete = text(
|
||||||
|
"insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
await session.execute(
|
await session.execute(requete, {"e": saisie})
|
||||||
text(
|
|
||||||
"insert into app_user (email, password_hash, role) "
|
|
||||||
"values (:e, '$argon2id$x', 'lecteur')"
|
|
||||||
),
|
|
||||||
{"e": saisie},
|
|
||||||
)
|
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.repositories.drift import ComptageStatut, PaireDerive
|
||||||
|
from app.services.drift import (
|
||||||
|
STATUT_DERIVE,
|
||||||
|
STATUT_INDETERMINE,
|
||||||
|
STATUT_STABLE,
|
||||||
|
DriftService,
|
||||||
|
Seuils,
|
||||||
|
mesure,
|
||||||
|
)
|
||||||
|
|
||||||
|
INSTANT = datetime(2026, 9, 22, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def paire(
|
||||||
|
*, site_id: str = "SITE001", prevu: float, reel: float, reference: str = "lightgbm-aaa"
|
||||||
|
) -> PaireDerive:
|
||||||
|
return PaireDerive(
|
||||||
|
site_id=site_id,
|
||||||
|
target_at=INSTANT,
|
||||||
|
predicted_value=prevu,
|
||||||
|
actual_value=reel,
|
||||||
|
model_reference=reference,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def paires(
|
||||||
|
*, site_id: str = "SITE001", nombre: int, prevu: float, reel: float
|
||||||
|
) -> list[PaireDerive]:
|
||||||
|
return [paire(site_id=site_id, prevu=prevu, reel=reel) for _ in range(nombre)]
|
||||||
|
|
||||||
|
|
||||||
|
class FauxDepot:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
recentes: Sequence[PaireDerive] = (),
|
||||||
|
anciennes: Sequence[PaireDerive] = (),
|
||||||
|
comptages: Sequence[ComptageStatut] = (),
|
||||||
|
) -> None:
|
||||||
|
self.recentes = list(recentes)
|
||||||
|
self.anciennes = list(anciennes)
|
||||||
|
self._comptages = list(comptages)
|
||||||
|
self.fenetres: list[tuple[datetime, datetime]] = []
|
||||||
|
|
||||||
|
async def paires(
|
||||||
|
self, *, debut: datetime, fin: datetime, site_id: str | None = None
|
||||||
|
) -> Sequence[PaireDerive]:
|
||||||
|
self.fenetres.append((debut, fin))
|
||||||
|
return self.recentes if len(self.fenetres) == 1 else self.anciennes
|
||||||
|
|
||||||
|
async def comptages(
|
||||||
|
self, *, debut: datetime, fin: datetime, site_id: str | None = None
|
||||||
|
) -> Sequence[ComptageStatut]:
|
||||||
|
return self._comptages
|
||||||
|
|
||||||
|
|
||||||
|
def service(depot: FauxDepot, **surcharges: object) -> DriftService:
|
||||||
|
return DriftService(depot, seuils=Seuils(**surcharges)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_averages_the_absolute_gap_between_forecast_and_actual() -> None:
|
||||||
|
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=8.0, reel=10.0)])
|
||||||
|
|
||||||
|
assert metriques.mae == 2.0
|
||||||
|
assert metriques.n_observations == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_computes_a_signed_bias_when_the_model_overforecasts() -> None:
|
||||||
|
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=14.0, reel=10.0)])
|
||||||
|
|
||||||
|
assert metriques.bias == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_computes_a_negative_bias_when_the_model_underforecasts() -> None:
|
||||||
|
metriques = mesure([paire(prevu=8.0, reel=10.0), paire(prevu=6.0, reel=10.0)])
|
||||||
|
|
||||||
|
assert metriques.bias == -3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_excludes_a_zero_actual_from_the_mape_only() -> None:
|
||||||
|
metriques = mesure([paire(prevu=11.0, reel=10.0), paire(prevu=5.0, reel=0.0)])
|
||||||
|
|
||||||
|
assert metriques.mape == 10.0
|
||||||
|
assert metriques.n_observations == 2
|
||||||
|
assert metriques.mae == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_reports_no_mape_when_every_actual_is_zero() -> None:
|
||||||
|
metriques = mesure([paire(prevu=1.0, reel=0.0)])
|
||||||
|
|
||||||
|
assert metriques.mape is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_drift_lists_every_model_reference_seen_in_the_window() -> None:
|
||||||
|
metriques = mesure(
|
||||||
|
[paire(prevu=10.0, reel=10.0, reference="lightgbm-bbb"), paire(prevu=10.0, reel=10.0)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert metriques.model_references == ["lightgbm-aaa", "lightgbm-bbb"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_reports_indetermine_when_the_window_holds_too_few_observations() -> None:
|
||||||
|
depot = FauxDepot(recentes=paires(nombre=3, prevu=10.0, reel=10.0))
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=24).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
assert {rapport.status for rapport in rapports} == {STATUT_INDETERMINE}
|
||||||
|
assert all(rapport.reason for rapport in rapports)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_reports_derive_when_the_recent_mae_exceeds_the_reference_ratio() -> None:
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=14.0, reel=10.0),
|
||||||
|
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
|
||||||
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||||
|
assert global_.status == STATUT_DERIVE
|
||||||
|
assert global_.mae == 4.0
|
||||||
|
assert global_.reference_mae == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_reports_stable_when_the_recent_mae_stays_close_to_the_reference() -> None:
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=11.0, reel=10.0),
|
||||||
|
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
|
||||||
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||||
|
assert global_.status == STATUT_STABLE
|
||||||
|
assert global_.reason is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_reports_derive_when_the_coverage_ratio_falls_under_the_threshold() -> None:
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
|
||||||
|
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
|
||||||
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=100)],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||||
|
assert global_.status == STATUT_DERIVE
|
||||||
|
assert global_.coverage_ratio == 0.3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_reports_one_line_per_site_and_one_global_line() -> None:
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=[
|
||||||
|
*paires(site_id="SITE001", nombre=12, prevu=10.0, reel=10.0),
|
||||||
|
*paires(site_id="SITE002", nombre=12, prevu=10.0, reel=10.0),
|
||||||
|
],
|
||||||
|
comptages=[
|
||||||
|
ComptageStatut(site_id="SITE001", status="available", nombre=12),
|
||||||
|
ComptageStatut(site_id="SITE002", status="available", nombre=12),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
assert [rapport.site_id for rapport in rapports] == ["SITE001", "SITE002", None]
|
||||||
|
assert next(r for r in rapports if r.site_id is None).n_observations == 24
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_measures_the_share_of_sites_left_without_enough_history() -> None:
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
|
||||||
|
comptages=[
|
||||||
|
ComptageStatut(site_id="SITE001", status="available", nombre=30),
|
||||||
|
ComptageStatut(site_id="SITE001", status="insufficient_data", nombre=10),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
assert next(r for r in rapports if r.site_id is None).insufficient_data_ratio == 0.25
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_closes_the_window_before_the_grace_delay() -> None:
|
||||||
|
depot = FauxDepot()
|
||||||
|
|
||||||
|
await service(depot, grace=timedelta(hours=2), fenetre=timedelta(hours=168)).evaluate(
|
||||||
|
now=INSTANT
|
||||||
|
)
|
||||||
|
|
||||||
|
recente, reference = depot.fenetres
|
||||||
|
assert recente[1] == INSTANT - timedelta(hours=2)
|
||||||
|
assert recente[0] == INSTANT - timedelta(hours=170)
|
||||||
|
assert reference[1] == recente[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("prevu", "attendu"),
|
||||||
|
[(10.0, STATUT_STABLE), (30.0, STATUT_DERIVE)],
|
||||||
|
ids=["mae_stable", "mae_triplee"],
|
||||||
|
)
|
||||||
|
async def test_drift_compares_the_recent_window_to_the_reference_one(
|
||||||
|
prevu: float, attendu: str
|
||||||
|
) -> None:
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=prevu, reel=10.0),
|
||||||
|
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
|
||||||
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10, mae_plancher=1.0).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
assert next(r for r in rapports if r.site_id is None).status == attendu
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_leaves_the_bias_out_of_the_verdict_by_default() -> None:
|
||||||
|
# Le modèle surestime de 3 kWh à chaque heure, et le verdict reste `stable` : le biais est
|
||||||
|
# mesuré et servi, il ne juge pas tant que `--bias-threshold` n'a pas été réglé (ADR 0013).
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=13.0, reel=10.0),
|
||||||
|
anciennes=paires(nombre=30, prevu=13.0, reel=10.0),
|
||||||
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||||
|
assert global_.status == STATUT_STABLE
|
||||||
|
assert global_.bias == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("prevu", "attendu"),
|
||||||
|
[(13.0, STATUT_DERIVE), (11.0, STATUT_STABLE)],
|
||||||
|
ids=["biais_au_dela", "biais_sous_le_seuil"],
|
||||||
|
)
|
||||||
|
async def test_drift_reports_derive_on_the_bias_once_a_threshold_is_set(
|
||||||
|
prevu: float, attendu: str
|
||||||
|
) -> None:
|
||||||
|
# MAE récente et MAE de référence sont égales : seul le biais peut faire basculer le verdict.
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=prevu, reel=10.0),
|
||||||
|
anciennes=paires(nombre=30, prevu=prevu, reel=10.0),
|
||||||
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10, seuil_biais=2.0).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||||
|
assert global_.status == attendu
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drift_prefers_the_mae_reason_when_both_the_mae_and_the_bias_exceed() -> None:
|
||||||
|
depot = FauxDepot(
|
||||||
|
recentes=paires(nombre=30, prevu=20.0, reel=10.0),
|
||||||
|
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
|
||||||
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||||
|
)
|
||||||
|
|
||||||
|
rapports = await service(depot, min_observations=10, seuil_biais=2.0).evaluate(now=INSTANT)
|
||||||
|
|
||||||
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||||
|
assert global_.status == STATUT_DERIVE
|
||||||
|
assert "MAE" in (global_.reason or "")
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Piege : ce fichier porte le marqueur `chaine`, pas `integration` - test_the_ml_binaries...()
|
||||||
|
|
||||||
|
Il lance les vrais binaires `enervision_ml.train` et `enervision_ml.score` dans l'environnement
|
||||||
|
uv de `ml/`, que le job `integration` de `backend.yml` n'installe pas. Un marqueur distinct evite
|
||||||
|
que ce job, et `make test`, ne le selectionnent et n'echouent faute de `ml/.venv`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from collections.abc import AsyncIterator, Iterator
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import delete, insert, make_url
|
||||||
|
|
||||||
|
from app.api.deps import get_current_principal
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.principal import Principal
|
||||||
|
from app.core.roles import AccountKind, Role
|
||||||
|
from app.db.session import get_session_factory
|
||||||
|
from app.models.energy import Prediction, Reading, Site
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.chaine
|
||||||
|
|
||||||
|
RACINE = Path(__file__).resolve().parents[3]
|
||||||
|
ML = RACINE / "ml"
|
||||||
|
PYTHON_ML = Path(os.environ.get("ML_PYTHON", ML / ".venv" / "bin" / "python"))
|
||||||
|
|
||||||
|
HEURES_COMPLETES = 400
|
||||||
|
HEURES_INSUFFISANTES = 100
|
||||||
|
|
||||||
|
|
||||||
|
def lecteur() -> Principal:
|
||||||
|
return Principal(
|
||||||
|
id=uuid4(),
|
||||||
|
email="lecteur@enervision.fr",
|
||||||
|
role=Role.LECTEUR,
|
||||||
|
kind=AccountKind.HUMAIN,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def url_ml() -> str:
|
||||||
|
"""Derive la chaine du pipeline de celle du backend plutot que de la recopier : les deux
|
||||||
|
cotes visent ainsi la meme base, dans leur dialecte respectif."""
|
||||||
|
return (
|
||||||
|
make_url(get_settings().database_url)
|
||||||
|
.set(drivername="postgresql+psycopg")
|
||||||
|
.render_as_string(hide_password=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lance_ml(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
if not PYTHON_ML.exists():
|
||||||
|
pytest.fail(
|
||||||
|
f"Environnement ml/ absent ({PYTHON_ML}). Lancer `cd ml && uv sync --all-groups`."
|
||||||
|
)
|
||||||
|
|
||||||
|
return subprocess.run( # noqa: S603 -- argv en liste, sans shell, binaire resolu dans le depot
|
||||||
|
[str(PYTHON_ML), "-m", module, *arguments],
|
||||||
|
cwd=ML,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=600,
|
||||||
|
check=False,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"ML_DATABASE_URL": url_ml(),
|
||||||
|
"MLFLOW_TRACKING_URI": f"sqlite:///{journal}/mlflow.db",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def executer(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
resultat = await anyio.to_thread.run_sync(
|
||||||
|
partial(lance_ml, module, *arguments, journal=journal)
|
||||||
|
)
|
||||||
|
assert resultat.returncode == 0, resultat.stderr
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Parc:
|
||||||
|
sites: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def lignes_horaires(site_id: str, *, heures: int, fin: datetime) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"site_id": site_id,
|
||||||
|
"timestamp": fin - timedelta(hours=decalage),
|
||||||
|
"source": "api_history",
|
||||||
|
"consumption_kwh": 50.0 + math.sin(decalage / 12.0) * 10.0,
|
||||||
|
"temperature_celsius": 15.0,
|
||||||
|
"humidity_percent": 50.0,
|
||||||
|
"solar_irradiance_wm2": 0.0,
|
||||||
|
"is_working_hours": True,
|
||||||
|
"raw_data": {},
|
||||||
|
}
|
||||||
|
for decalage in reversed(range(heures))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def parc() -> AsyncIterator[Parc]:
|
||||||
|
"""Deux sites dotes d'un historique complet, un troisieme qui n'atteint pas le lag de 168 h.
|
||||||
|
|
||||||
|
Les ecritures sont validees : les binaires ML ouvrent leur propre connexion et ne verraient
|
||||||
|
pas une transaction en cours.
|
||||||
|
"""
|
||||||
|
fin = datetime.now(UTC).replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||||
|
marque = uuid4().hex[:12]
|
||||||
|
complets = [f"TEST-{marque}-A", f"TEST-{marque}-B"]
|
||||||
|
partiel = f"TEST-{marque}-C"
|
||||||
|
parc = Parc(sites=[*complets, partiel])
|
||||||
|
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
await session.execute(
|
||||||
|
insert(Site),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"site_id": site_id,
|
||||||
|
"site_name": f"Site {site_id}",
|
||||||
|
"site_type": "office",
|
||||||
|
"capacity_kw": 100.0,
|
||||||
|
}
|
||||||
|
for site_id in parc.sites
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for site_id in complets:
|
||||||
|
await session.execute(
|
||||||
|
insert(Reading), lignes_horaires(site_id, heures=HEURES_COMPLETES, fin=fin)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
insert(Reading), lignes_horaires(partiel, heures=HEURES_INSUFFISANTES, fin=fin)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield parc
|
||||||
|
finally:
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
await session.execute(delete(Prediction).where(Prediction.site_id.in_(parc.sites)))
|
||||||
|
await session.execute(delete(Reading).where(Reading.site_id.in_(parc.sites)))
|
||||||
|
await session.execute(delete(Site).where(Site.site_id.in_(parc.sites)))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def principal_lecteur(app: FastAPI) -> Iterator[None]:
|
||||||
|
app.dependency_overrides[get_current_principal] = lecteur
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.pop(get_current_principal, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def resume_du_site(client: AsyncClient, site_id: str) -> dict[str, Any]:
|
||||||
|
reponse = await client.get("/api/v1/predictions")
|
||||||
|
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
sites = reponse.json()["sites"]
|
||||||
|
return next(site for site in sites if site["site_id"] == site_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def entraine_et_score(parc: Parc, tmp_path: Path, *arguments: str) -> Path:
|
||||||
|
modele = tmp_path / "lightgbm-consumption.txt"
|
||||||
|
|
||||||
|
await executer(
|
||||||
|
"enervision_ml.train",
|
||||||
|
"--model-output",
|
||||||
|
str(modele),
|
||||||
|
"--mlflow-tracking-uri",
|
||||||
|
f"sqlite:///{tmp_path}/mlflow.db",
|
||||||
|
journal=tmp_path,
|
||||||
|
)
|
||||||
|
await executer("enervision_ml.score", "--model", str(modele), *arguments, journal=tmp_path)
|
||||||
|
|
||||||
|
return modele
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_ml_binaries_produce_a_prediction_that_the_api_serves(
|
||||||
|
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
|
||||||
|
) -> None:
|
||||||
|
await entraine_et_score(parc, tmp_path)
|
||||||
|
|
||||||
|
servi = await resume_du_site(client, parc.sites[0])
|
||||||
|
|
||||||
|
assert servi["prediction"]["status"] == "available"
|
||||||
|
assert servi["prediction"]["predicted_value"] is not None
|
||||||
|
assert servi["prediction"]["target_metric"] == "consumption_kwh"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_api_exposes_the_failure_reason_of_a_site_without_enough_history(
|
||||||
|
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
|
||||||
|
) -> None:
|
||||||
|
await entraine_et_score(parc, tmp_path)
|
||||||
|
|
||||||
|
servi = await resume_du_site(client, parc.sites[-1])
|
||||||
|
|
||||||
|
assert servi["prediction"]["status"] == "insufficient_data"
|
||||||
|
assert servi["prediction"]["predicted_value"] is None
|
||||||
|
assert servi["prediction"]["failure_reason"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_api_serves_the_latest_run_when_the_score_cli_runs_twice(
|
||||||
|
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
|
||||||
|
) -> None:
|
||||||
|
modele = await entraine_et_score(parc, tmp_path)
|
||||||
|
premier = await resume_du_site(client, parc.sites[0])
|
||||||
|
|
||||||
|
await executer("enervision_ml.score", "--model", str(modele), journal=tmp_path)
|
||||||
|
|
||||||
|
second = await resume_du_site(client, parc.sites[0])
|
||||||
|
assert second["prediction"]["created_at"] >= premier["prediction"]["created_at"]
|
||||||
|
assert second["prediction"]["model_reference"] == premier["prediction"]["model_reference"]
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.monitoring import drift as cli
|
||||||
|
from app.repositories.drift import NouveauRapportDerive
|
||||||
|
from app.services.drift import STATUT_DERIVE, STATUT_STABLE, Seuils
|
||||||
|
|
||||||
|
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def rapport(*, site_id: str | None, status: str, reason: str | None = None) -> NouveauRapportDerive:
|
||||||
|
return NouveauRapportDerive(
|
||||||
|
site_id=site_id,
|
||||||
|
window_start=INSTANT - timedelta(hours=168),
|
||||||
|
window_end=INSTANT,
|
||||||
|
reference_start=None,
|
||||||
|
reference_end=None,
|
||||||
|
n_observations=48,
|
||||||
|
mae=1.5,
|
||||||
|
mape=12.0,
|
||||||
|
bias=0.3,
|
||||||
|
reference_mae=1.2,
|
||||||
|
coverage_ratio=1.0,
|
||||||
|
insufficient_data_ratio=0.0,
|
||||||
|
model_references=["lightgbm-aaa"],
|
||||||
|
status=status,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def installe(monkeypatch: pytest.MonkeyPatch, rapports: list[NouveauRapportDerive]) -> None:
|
||||||
|
async def fausse_execution(
|
||||||
|
*, now: datetime | None, site_id: str | None, seuils: Seuils | None
|
||||||
|
) -> list[NouveauRapportDerive]:
|
||||||
|
return rapports
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "run_drift", fausse_execution)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_defaults_to_the_standard_window() -> None:
|
||||||
|
arguments = cli.parse_args([])
|
||||||
|
|
||||||
|
assert arguments.window_hours == 168
|
||||||
|
assert arguments.grace_hours == 2
|
||||||
|
assert arguments.fail_on_drift is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_reads_the_site_id() -> None:
|
||||||
|
assert cli.parse_args(["--site-id", "SITE001"]).site_id == "SITE001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_parses_the_instant_option() -> None:
|
||||||
|
arguments = cli.parse_args(["--now", "2026-09-22T12:00:00+00:00"])
|
||||||
|
|
||||||
|
assert arguments.now == INSTANT
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_instant_treats_a_naive_datetime_as_utc() -> None:
|
||||||
|
assert cli._parse_instant("2026-09-22T12:00:00") == INSTANT
|
||||||
|
|
||||||
|
|
||||||
|
def test_seuils_depuis_translates_the_hour_options_into_durations() -> None:
|
||||||
|
seuils = cli.seuils_depuis(cli.parse_args(["--window-hours", "24", "--grace-hours", "1"]))
|
||||||
|
|
||||||
|
assert seuils.fenetre == timedelta(hours=24)
|
||||||
|
assert seuils.grace == timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_prints_the_verdict_of_every_line(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
installe(
|
||||||
|
monkeypatch,
|
||||||
|
[
|
||||||
|
rapport(site_id="SITE001", status=STATUT_STABLE),
|
||||||
|
rapport(site_id=None, status=STATUT_STABLE),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
code = cli.main([])
|
||||||
|
|
||||||
|
sortie = capsys.readouterr().out
|
||||||
|
assert code == 0
|
||||||
|
assert "SITE001" in sortie
|
||||||
|
assert "TOUS SITES" in sortie
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_exits_non_zero_when_drift_is_detected_and_the_flag_is_set(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
|
||||||
|
|
||||||
|
code = cli.main(["--fail-on-drift"])
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert "MAE doublée" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_exits_zero_when_drift_is_detected_without_the_flag(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
|
||||||
|
|
||||||
|
code = cli.main([])
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
assert capsys.readouterr().out != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_leaves_the_bias_threshold_disabled_by_default() -> None:
|
||||||
|
assert cli.parse_args([]).bias_threshold == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_seuils_depuis_carries_the_bias_threshold() -> None:
|
||||||
|
assert cli.seuils_depuis(cli.parse_args(["--bias-threshold", "2.5"])).seuil_biais == 2.5
|
||||||
Generated
+37
-37
@@ -655,31 +655,31 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pandas"
|
name = "pandas"
|
||||||
version = "3.0.5"
|
version = "3.0.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "python-dateutil" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -936,27 +936,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.16.7"
|
version = "0.16.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# ==================
|
# ==================
|
||||||
|
|
||||||
# Image pour frontend
|
# Image pour frontend
|
||||||
FROM node:24-alpine3.22 AS builder
|
FROM node:26-alpine3.22 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -23,23 +23,31 @@ RUN npm run build
|
|||||||
# ==================
|
# ==================
|
||||||
|
|
||||||
|
|
||||||
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner
|
FROM nginx:1.31-alpine AS runner
|
||||||
|
|
||||||
# Copie de la configuration de nginx
|
# Copie de la configuration de nginx
|
||||||
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
|
COPY --chown=root:root nginx.conf /etc/nginx/nginx.conf
|
||||||
|
RUN chmod 755 /etc/nginx/nginx.conf
|
||||||
|
|
||||||
# Copy the static build output from the build stage to Nginx's default HTML serving directory
|
# Copy the static build output from the build stage to Nginx's default HTML serving directory
|
||||||
COPY --chown=root:root --chmod=755 --from=builder /app/dist/*/browser /usr/share/nginx/html
|
COPY --chown=root:root --from=builder /app/dist/frontend/browser /usr/share/nginx/html
|
||||||
|
RUN chmod 755 /usr/share/nginx/html
|
||||||
|
|
||||||
|
|
||||||
# Create necessary directories with proper permissions for nginx
|
# Create necessary directories with proper permissions for nginx
|
||||||
RUN mkdir -p /var/log/nginx /var/cache/nginx && \
|
RUN mkdir -p /var/log/nginx /var/cache/nginx && \
|
||||||
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html
|
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html &&\
|
||||||
|
chmod 755 /var/log/nginx /var/log
|
||||||
|
|
||||||
|
# Permissions appropriées
|
||||||
|
RUN find /usr/share/nginx/html -type f -exec chmod 644 {} \; && \
|
||||||
|
find /usr/share/nginx/html -type d -exec chmod 755 {} \; && \
|
||||||
|
chown -R nginx:nginx /var/log/nginx /var/cache/nginx
|
||||||
|
|
||||||
# Use a non-root user for security best practices
|
# Use a non-root user for security best practices
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
# Frontend : port 3000
|
# Frontend : port 3000
|
||||||
# Backend : port 8000
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start Nginx directly with custom config
|
# Start Nginx directly with custom config
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Conventions de tests unitaires — Frontend
|
# Conventions de tests unitaires : Frontend
|
||||||
|
|
||||||
## Outil
|
## Outil
|
||||||
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
|
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
|
||||||
@@ -83,3 +83,6 @@ describe('MonComposant', () => {
|
|||||||
## Lancer les tests
|
## Lancer les tests
|
||||||
- Développement (mode watch) : `npm test`
|
- Développement (mode watch) : `npm test`
|
||||||
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
|
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
|
||||||
|
- Un fichier ou un dossier seulement :
|
||||||
|
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
|
||||||
|
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"optimization": {
|
||||||
|
"styles": {
|
||||||
|
"inlineCritical": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
worker_processes auto;
|
worker_processes auto;
|
||||||
error_log /var/log/nginx/error.log warn;
|
error_log /dev/null warn; # ← Envoyer les logs vers /dev/null
|
||||||
pid /tmp/nginx.pid;
|
pid /tmp/nginx.pid;
|
||||||
|
|
||||||
events {
|
events {
|
||||||
@@ -9,15 +9,14 @@ events {
|
|||||||
http {
|
http {
|
||||||
include /etc/nginx/mime.types;
|
include /etc/nginx/mime.types;
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
|
access_log /dev/null; # ← Idem pour access logs
|
||||||
|
|
||||||
sendfile on;
|
sendfile on;
|
||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 3000;
|
listen 3000;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
|||||||
Generated
+95
-174
@@ -8,12 +8,12 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^22.1.0",
|
"@angular/common": "^22.1.7",
|
||||||
"@angular/compiler": "^22.1.0",
|
"@angular/compiler": "^22.1.7",
|
||||||
"@angular/core": "^22.1.0",
|
"@angular/core": "^22.1.7",
|
||||||
"@angular/forms": "^22.1.0",
|
"@angular/forms": "^22.1.7",
|
||||||
"@angular/platform-browser": "^22.1.0",
|
"@angular/platform-browser": "^22.1.7",
|
||||||
"@angular/router": "^22.1.0",
|
"@angular/router": "^22.1.7",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -21,21 +21,14 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^22.1.8",
|
"@angular/build": "^22.1.8",
|
||||||
"@angular/cli": "^22.1.8",
|
"@angular/cli": "^22.1.8",
|
||||||
"@angular/compiler-cli": "^22.1.0",
|
"@angular/compiler-cli": "^22.1.7",
|
||||||
"@vitest/coverage-v8": "^4.1.11",
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^30.1.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.9.8",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.1.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@acemir/cssom": {
|
|
||||||
"version": "0.9.31",
|
|
||||||
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
|
|
||||||
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
@@ -255,9 +248,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/common": {
|
"node_modules/@angular/common": {
|
||||||
"version": "22.1.6",
|
"version": "22.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.7.tgz",
|
||||||
"integrity": "sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==",
|
"integrity": "sha512-SLW4AdH2Y8G9SM/iFRNpCQtkmvq55RcIQWsADT9ypZ7FxxcnJRsr4Q/WnbUBbuCslsCkrgzU1i9WDiW0m7gcnw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -266,14 +259,14 @@
|
|||||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/core": "22.1.6",
|
"@angular/core": "22.1.7",
|
||||||
"rxjs": "^6.5.3 || ^7.4.0"
|
"rxjs": "^6.5.3 || ^7.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/compiler": {
|
"node_modules/@angular/compiler": {
|
||||||
"version": "22.1.6",
|
"version": "22.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.7.tgz",
|
||||||
"integrity": "sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==",
|
"integrity": "sha512-qhoHAByh805nT4Xy4Wd2kE5FvkTc7wcg8Qps+EH6+z4KRfwGiL5cwdgooGWjTAldFCeqTgJ0BO/0dB6GHZjdkQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -283,9 +276,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/compiler-cli": {
|
"node_modules/@angular/compiler-cli": {
|
||||||
"version": "22.1.6",
|
"version": "22.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.7.tgz",
|
||||||
"integrity": "sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==",
|
"integrity": "sha512-VGg8Kyt0YymQfnUUFk79vFaM+x5xmIMHy+BZh3P2Lh/uxqCXTWhJJH8kOHKtIGGH5/wKhBEmSy2CezdcIuM9xg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -306,7 +299,7 @@
|
|||||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/compiler": "22.1.6",
|
"@angular/compiler": "22.1.7",
|
||||||
"typescript": ">=6.0 <6.1"
|
"typescript": ">=6.0 <6.1"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -316,9 +309,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/core": {
|
"node_modules/@angular/core": {
|
||||||
"version": "22.1.6",
|
"version": "22.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.7.tgz",
|
||||||
"integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==",
|
"integrity": "sha512-T9bt1PIgoN7n+TxGHTUgu0EsomlGMQ84YMXbMB+N6HhD8C1qZxn0uCbnAtCUjcecg1am8ioKxNTO0eII9ouioQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -327,7 +320,7 @@
|
|||||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/compiler": "22.1.6",
|
"@angular/compiler": "22.1.7",
|
||||||
"rxjs": "^6.5.3 || ^7.4.0",
|
"rxjs": "^6.5.3 || ^7.4.0",
|
||||||
"zone.js": "~0.15.0 || ~0.16.0"
|
"zone.js": "~0.15.0 || ~0.16.0"
|
||||||
},
|
},
|
||||||
@@ -341,9 +334,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/forms": {
|
"node_modules/@angular/forms": {
|
||||||
"version": "22.1.6",
|
"version": "22.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.7.tgz",
|
||||||
"integrity": "sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==",
|
"integrity": "sha512-oc0DT39C3ZboJpDx8xKUCltgn56LHi0kbv+ThNK/dCSZLgjF2nd+muMMRYF+amdljQp2q7+hm2ORmhON3CNFog==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
@@ -354,16 +347,16 @@
|
|||||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/common": "22.1.6",
|
"@angular/common": "22.1.7",
|
||||||
"@angular/core": "22.1.6",
|
"@angular/core": "22.1.7",
|
||||||
"@angular/platform-browser": "22.1.6",
|
"@angular/platform-browser": "22.1.7",
|
||||||
"rxjs": "^6.5.3 || ^7.4.0"
|
"rxjs": "^6.5.3 || ^7.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/platform-browser": {
|
"node_modules/@angular/platform-browser": {
|
||||||
"version": "22.1.6",
|
"version": "22.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.7.tgz",
|
||||||
"integrity": "sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==",
|
"integrity": "sha512-4dT3qGw3ZHVQUcHKALzMx9MTo+pxAMzHIPxc8HUESklqXfq6GRaIIfFMvE0RsquI9iqu6admD6lOAEzLWS8DxA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -372,9 +365,9 @@
|
|||||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/animations": "22.1.6",
|
"@angular/animations": "22.1.7",
|
||||||
"@angular/common": "22.1.6",
|
"@angular/common": "22.1.7",
|
||||||
"@angular/core": "22.1.6"
|
"@angular/core": "22.1.7"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@angular/animations": {
|
"@angular/animations": {
|
||||||
@@ -383,9 +376,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular/router": {
|
"node_modules/@angular/router": {
|
||||||
"version": "22.1.6",
|
"version": "22.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.7.tgz",
|
||||||
"integrity": "sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==",
|
"integrity": "sha512-CxYa2Ym5L0WmqToHU2I0Z4VEpocdryLlvx5svLfo9M3uOaP3/9icB/gjS/vXPbbQxV8/GZ59E/7/LF14anIoRg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -394,60 +387,45 @@
|
|||||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/common": "22.1.6",
|
"@angular/common": "22.1.7",
|
||||||
"@angular/core": "22.1.6",
|
"@angular/core": "22.1.7",
|
||||||
"@angular/platform-browser": "22.1.6",
|
"@angular/platform-browser": "22.1.7",
|
||||||
"rxjs": "^6.5.3 || ^7.4.0"
|
"rxjs": "^6.5.3 || ^7.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "5.1.11",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
|
||||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
"integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
"@csstools/css-calc": "^3.3.0",
|
||||||
"@csstools/css-calc": "^3.2.0",
|
"@csstools/css-color-parser": "^4.2.0",
|
||||||
"@csstools/css-color-parser": "^4.1.0",
|
|
||||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
"@csstools/css-tokenizer": "^4.0.0"
|
"@csstools/css-tokenizer": "^4.0.0",
|
||||||
|
"lru-cache": "^11.5.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/dom-selector": {
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
"version": "6.8.1",
|
"version": "9.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
|
||||||
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
|
"integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
"bidi-js": "^1.1.0",
|
||||||
"bidi-js": "^1.0.3",
|
"css-tree": "^3.2.1",
|
||||||
"css-tree": "^3.1.0",
|
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
"lru-cache": "^11.2.6"
|
"lru-cache": "^11.5.2"
|
||||||
}
|
},
|
||||||
},
|
|
||||||
"node_modules/@asamuzakjp/generational-cache": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/nwsapi": {
|
|
||||||
"version": "2.3.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
|
||||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz",
|
||||||
@@ -854,9 +832,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
|
||||||
"integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==",
|
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -879,9 +857,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-tokenizer": {
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
|
||||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4490,22 +4468,6 @@
|
|||||||
"url": "https://github.com/sponsors/fb55"
|
"url": "https://github.com/sponsors/fb55"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cssstyle": {
|
|
||||||
"version": "6.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
|
|
||||||
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@asamuzakjp/css-color": "^5.0.1",
|
|
||||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
|
|
||||||
"css-tree": "^3.1.0",
|
|
||||||
"lru-cache": "^11.2.6"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
@@ -5283,30 +5245,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/http-proxy-agent": {
|
|
||||||
"version": "7.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
|
||||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "^7.1.0",
|
|
||||||
"debug": "^4.3.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/http-proxy-agent/node_modules/agent-base": {
|
|
||||||
"version": "7.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
|
||||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/https-proxy-agent": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
|
||||||
@@ -5529,39 +5467,38 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "28.1.0",
|
"version": "30.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
|
||||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
"integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.31",
|
"@asamuzakjp/css-color": "^7.0.0",
|
||||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
"@asamuzakjp/dom-selector": "^9.1.2",
|
||||||
"@bramus/specificity": "^2.4.2",
|
"@bramus/specificity": "^2.4.2",
|
||||||
"@exodus/bytes": "^1.11.0",
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
|
||||||
"cssstyle": "^6.0.1",
|
"@exodus/bytes": "^1.15.1",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
"data-urls": "^7.0.0",
|
"data-urls": "^7.0.0",
|
||||||
"decimal.js": "^10.6.0",
|
"decimal.js": "^10.6.0",
|
||||||
"html-encoding-sniffer": "^6.0.0",
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
"http-proxy-agent": "^7.0.2",
|
|
||||||
"https-proxy-agent": "^7.0.6",
|
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
"parse5": "^8.0.0",
|
"lru-cache": "^11.5.2",
|
||||||
|
"parse5": "^8.0.1",
|
||||||
"saxes": "^6.0.0",
|
"saxes": "^6.0.0",
|
||||||
"symbol-tree": "^3.2.4",
|
"tough-cookie": "^6.0.2",
|
||||||
"tough-cookie": "^6.0.0",
|
"undici": "^8.10.2",
|
||||||
"undici": "^7.21.0",
|
|
||||||
"w3c-xmlserializer": "^5.0.0",
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
"webidl-conversions": "^8.0.1",
|
"webidl-conversions": "^8.0.1",
|
||||||
"whatwg-mimetype": "^5.0.0",
|
"whatwg-mimetype": "^5.0.0",
|
||||||
"whatwg-url": "^16.0.0",
|
"whatwg-url": "^17.1.1",
|
||||||
"xml-name-validator": "^5.0.0"
|
"xml-name-validator": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"canvas": "^3.0.0"
|
"canvas": "^3.2.3"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"canvas": {
|
"canvas": {
|
||||||
@@ -5569,28 +5506,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsdom/node_modules/agent-base": {
|
"node_modules/jsdom/node_modules/whatwg-url": {
|
||||||
"version": "7.1.4",
|
"version": "17.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
|
||||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
"integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsdom/node_modules/https-proxy-agent": {
|
|
||||||
"version": "7.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
|
||||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"agent-base": "^7.1.2",
|
"@exodus/bytes": "^1.15.1",
|
||||||
"debug": "4"
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": "^22.14.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
@@ -6708,9 +6636,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.9.6",
|
"version": "3.9.8",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
|
||||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -7308,13 +7236,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/symbol-tree": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -7469,13 +7390,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.29.1",
|
"version": "8.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
|
||||||
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
|
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=22.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
|
|||||||
+11
-11
@@ -4,7 +4,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "node node_modules/@angular/cli/bin/ng.js build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"test:ci": "ng test --watch=false"
|
"test:ci": "ng test --watch=false"
|
||||||
@@ -12,12 +12,12 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.19.0",
|
"packageManager": "npm@11.19.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^22.1.0",
|
"@angular/common": "^22.1.7",
|
||||||
"@angular/compiler": "^22.1.0",
|
"@angular/compiler": "^22.1.7",
|
||||||
"@angular/core": "^22.1.0",
|
"@angular/core": "^22.1.7",
|
||||||
"@angular/forms": "^22.1.0",
|
"@angular/forms": "^22.1.7",
|
||||||
"@angular/platform-browser": "^22.1.0",
|
"@angular/platform-browser": "^22.1.7",
|
||||||
"@angular/router": "^22.1.0",
|
"@angular/router": "^22.1.7",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -25,11 +25,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^22.1.8",
|
"@angular/build": "^22.1.8",
|
||||||
"@angular/cli": "^22.1.8",
|
"@angular/cli": "^22.1.8",
|
||||||
"@angular/compiler-cli": "^22.1.0",
|
"@angular/compiler-cli": "^22.1.7",
|
||||||
"@vitest/coverage-v8": "^4.1.11",
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^30.1.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.9.8",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,36 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import {authGuard} from './core/guards/auth-guard';
|
import { authGuard } from './core/guards/auth-guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
|
{
|
||||||
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
|
path: 'login',
|
||||||
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
|
loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
|
||||||
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
|
},
|
||||||
|
{
|
||||||
|
path: 'change-password',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/change-password/change-password').then((m) => m.ChangePassword),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgot-password',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reset-password',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
|
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'sites',
|
path: 'sites',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
|
loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'sites/:siteId',
|
path: 'sites/:siteId',
|
||||||
@@ -23,4 +38,17 @@ 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: 'recommendations',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/recommendations/recommendations').then((m) => m.RecommendationsView),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'monitoring/sensors',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
data: { role: 'admin' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
|
|||||||
describe('mockApiInterceptor', () => {
|
describe('mockApiInterceptor', () => {
|
||||||
let http: HttpClient;
|
let http: HttpClient;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
let useMockFixturesInitial: boolean;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
useMockFixturesInitial = environment.useMockFixtures;
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
||||||
@@ -21,7 +23,7 @@ describe('mockApiInterceptor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
environment.useMockFixtures = true;
|
environment.useMockFixtures = useMockFixturesInitial;
|
||||||
httpMock.verify();
|
httpMock.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
|
|||||||
|
|
||||||
export const ALERTS_FIXTURE: Alert[] = [
|
export const ALERTS_FIXTURE: Alert[] = [
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE002-1718458320',
|
alert_id: 5,
|
||||||
timestamp: '2026-09-15T11:12:00',
|
|
||||||
site_id: 'SITE002',
|
site_id: 'SITE002',
|
||||||
|
timestamp: '2026-09-15T11:12:00Z',
|
||||||
|
type: 'threshold',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
type: 'outage',
|
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
|
||||||
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
|
|
||||||
value: 812.5,
|
value: 812.5,
|
||||||
threshold: 720.0,
|
threshold: 720.0,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE003-1718458321',
|
alert_id: 4,
|
||||||
timestamp: '2026-09-15T11:05:00',
|
|
||||||
site_id: 'SITE003',
|
site_id: 'SITE003',
|
||||||
|
timestamp: '2026-09-15T11:05:00Z',
|
||||||
|
type: 'outage',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
type: 'sensor',
|
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
|
||||||
message: 'Perte réseau totale sur Data Center Marseille',
|
value: null,
|
||||||
value: 0,
|
threshold: null,
|
||||||
threshold: 0,
|
metric: null,
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE005-1718458322',
|
alert_id: 3,
|
||||||
timestamp: '2026-09-15T10:47:00',
|
|
||||||
site_id: 'SITE005',
|
site_id: 'SITE005',
|
||||||
|
timestamp: '2026-09-15T10:47:00Z',
|
||||||
|
type: 'spike',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
type: 'threshold',
|
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
|
||||||
message: 'Usine Toulouse approche de son seuil de capacité',
|
|
||||||
value: 410.0,
|
value: 410.0,
|
||||||
threshold: 480.0,
|
threshold: 260.0,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE006-1718458323',
|
alert_id: 2,
|
||||||
timestamp: '2026-09-15T10:30:00',
|
|
||||||
site_id: 'SITE006',
|
site_id: 'SITE006',
|
||||||
severity: 'medium',
|
timestamp: '2026-09-15T10:30:00Z',
|
||||||
type: 'sensor',
|
type: 'sensor',
|
||||||
message: 'Capteur de température défaillant sur Bureau Lille',
|
severity: 'medium',
|
||||||
value: 0,
|
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
|
||||||
threshold: 0,
|
value: null,
|
||||||
|
threshold: null,
|
||||||
|
metric: null,
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE004-1718458324',
|
alert_id: 1,
|
||||||
timestamp: '2026-09-15T09:58:00',
|
|
||||||
site_id: 'SITE004',
|
site_id: 'SITE004',
|
||||||
severity: 'low',
|
timestamp: '2026-09-15T09:58:00Z',
|
||||||
type: 'anomaly',
|
type: 'anomaly',
|
||||||
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
|
severity: 'low',
|
||||||
|
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
|
||||||
value: 62.0,
|
value: 62.0,
|
||||||
threshold: 55.0,
|
threshold: 55.0,
|
||||||
|
metric: 'consumption_kwh',
|
||||||
|
prediction_id: 42,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http';
|
|||||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
import { AlertsService } from './alerts.service';
|
import { AlertsService } from './alerts.service';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Alert } from '../../shared/models/alert.model';
|
||||||
|
|
||||||
|
const ALERT_API: Alert = {
|
||||||
|
alert_id: 1,
|
||||||
|
site_id: 'site-1',
|
||||||
|
timestamp: '2026-09-16T00:00:00Z',
|
||||||
|
type: 'threshold',
|
||||||
|
severity: 'high',
|
||||||
|
message: 'Dépassement du seuil configuré',
|
||||||
|
value: 812.5,
|
||||||
|
threshold: 720.0,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
describe('AlertsService', () => {
|
describe('AlertsService', () => {
|
||||||
let service: AlertsService;
|
let service: AlertsService;
|
||||||
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
|
|||||||
|
|
||||||
afterEach(() => httpMock.verify());
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
|
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
|
||||||
let result: unknown;
|
let result: Alert[] = [];
|
||||||
service.getAlerts().subscribe((r) => (result = r));
|
service.getAlerts().subscribe((r) => (result = r));
|
||||||
|
|
||||||
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
|
const req = httpMock.expectOne(
|
||||||
expect(req.request.method).toBe('GET');
|
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
|
||||||
|
);
|
||||||
|
expect(req.request.params.keys()).toEqual([]);
|
||||||
|
req.flush([ALERT_API]);
|
||||||
|
|
||||||
req.flush([
|
expect(result.length).toBe(1);
|
||||||
{
|
expect(result[0].alert_id).toBe(1);
|
||||||
alert_id: 'ALR-TEST-1',
|
expect(result[0].prediction_id).toBeNull();
|
||||||
timestamp: '2026-09-15T12:00:00',
|
});
|
||||||
site_id: 'SITE001',
|
|
||||||
severity: 'high',
|
|
||||||
type: 'threshold',
|
|
||||||
message: 'Test',
|
|
||||||
value: 100,
|
|
||||||
threshold: 90,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect((result as unknown[]).length).toBe(1);
|
it('transmet les filtres site_id et severity en paramètres de requête', () => {
|
||||||
|
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||||
|
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||||
|
expect(req.request.params.get('severity')).toBe('high');
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne pose pas de paramètre pour un filtre omis', () => {
|
||||||
|
service.getAlerts({ site_id: 'SITE001' }).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||||
|
expect(req.request.params.has('severity')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { Service, inject } from '@angular/core';
|
import { Service, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { Alert } from '../../shared/models/alert.model';
|
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
||||||
|
|
||||||
|
export interface AlertFilters {
|
||||||
|
site_id?: string;
|
||||||
|
severity?: AlertSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class AlertsService {
|
export class AlertsService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
getAlerts() {
|
getAlerts(filters: AlertFilters = {}) {
|
||||||
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
|
let params = new HttpParams();
|
||||||
|
if (filters.site_id) {
|
||||||
|
params = params.set('site_id', filters.site_id);
|
||||||
|
}
|
||||||
|
if (filters.severity) {
|
||||||
|
params = params.set('severity', filters.severity);
|
||||||
|
}
|
||||||
|
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { RecommendationsService } from './recommendations.service';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Recommendation } from '../../shared/models/recommendation.model';
|
||||||
|
|
||||||
|
const RECOMMANDATION_API: Recommendation = {
|
||||||
|
recommendation_id: 1,
|
||||||
|
alert_id: 1,
|
||||||
|
action: 'Vérifier la consommation',
|
||||||
|
explanation: 'Pic détecté',
|
||||||
|
rule_reference: 'spike-v1',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RecommendationsService', () => {
|
||||||
|
let service: RecommendationsService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(RecommendationsService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('liste les recommandations depuis le bon endpoint', () => {
|
||||||
|
let result: Recommendation[] = [];
|
||||||
|
service.getRecommendations().subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush([RECOMMANDATION_API]);
|
||||||
|
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].alert_id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('décrit une recommandation par son identifiant', () => {
|
||||||
|
service.getRecommendation(42).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations/42`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush({ ...RECOMMANDATION_API, recommendation_id: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('déclenche la génération en POST avec le site en paramètre de requête', () => {
|
||||||
|
let result: unknown;
|
||||||
|
service.generate('SITE001').subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(
|
||||||
|
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
|
||||||
|
);
|
||||||
|
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||||
|
expect(req.request.body).toBeNull();
|
||||||
|
req.flush({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
|
||||||
|
|
||||||
|
expect(result).toEqual({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('génère pour tout le parc quand aucun site n’est donné', () => {
|
||||||
|
service.generate().subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(
|
||||||
|
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
|
||||||
|
);
|
||||||
|
expect(req.request.params.has('site_id')).toBe(false);
|
||||||
|
req.flush({ alerts_examined: 0, recommendations_created: 0, already_present: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
Recommendation,
|
||||||
|
RecommendationGenerationReport,
|
||||||
|
} from '../../shared/models/recommendation.model';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RecommendationsService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
getRecommendations() {
|
||||||
|
return this.http.get<Recommendation[]>(`${environment.apiUrl}/recommendations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecommendation(recommendationId: number) {
|
||||||
|
return this.http.get<Recommendation>(
|
||||||
|
`${environment.apiUrl}/recommendations/${recommendationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(siteId?: string) {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (siteId) {
|
||||||
|
params = params.set('site_id', siteId);
|
||||||
|
}
|
||||||
|
return this.http.post<RecommendationGenerationReport>(
|
||||||
|
`${environment.apiUrl}/recommendations/generate`,
|
||||||
|
null,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 +10,11 @@
|
|||||||
</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>
|
||||||
|
<a routerLink="/recommendations" class="ev-link">Recommandations</a>
|
||||||
<ev-button
|
<ev-button
|
||||||
class="logout-button"
|
class="logout-button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -24,87 +28,120 @@
|
|||||||
@if (statsError(); as message) {
|
@if (statsError(); as message) {
|
||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
}
|
}
|
||||||
@if (alertsError(); as message) {
|
|
||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
|
||||||
}
|
|
||||||
@if (predictionsError(); as message) {
|
@if (predictionsError(); as message) {
|
||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (stats(); as s) {
|
@if (stats(); as s) {
|
||||||
<section class="overview">
|
<p class="dashboard__status">
|
||||||
<ev-card class="card card--gauge">
|
<span class="dashboard__pulse" aria-hidden="true"></span>
|
||||||
<span class="card__label">Consommation vs capacité</span>
|
Actualisé à {{ s.timestamp | date: 'HH:mm:ss' }} · {{ s.total_sites }} sites suivis
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="overview" aria-label="Indicateurs du parc">
|
||||||
|
<ev-card class="kpi kpi--gauge">
|
||||||
|
<span class="kpi__label">Consommation vs capacité</span>
|
||||||
<app-consumption-gauge
|
<app-consumption-gauge
|
||||||
[consumption]="s.total_consumption_kw"
|
[consumption]="s.total_consumption_kw"
|
||||||
[capacity]="s.total_capacity_kw"
|
[capacity]="s.total_capacity_kw"
|
||||||
/>
|
/>
|
||||||
<span class="card__value"
|
<span class="kpi__value">
|
||||||
>{{ s.total_consumption_kw | number: '1.0-1' }} /
|
{{ s.total_consumption_kw | number: '1.0-1' }}
|
||||||
{{ s.total_capacity_kw | number }} kW</span
|
<small>/ {{ s.total_capacity_kw | number }} kW</small>
|
||||||
|
</span>
|
||||||
|
</ev-card>
|
||||||
|
|
||||||
|
<ev-card class="kpi">
|
||||||
|
<span class="kpi__label">Charge moyenne du parc</span>
|
||||||
|
<span class="kpi__value"
|
||||||
|
>{{ s.average_load_percent | number: '1.0-0' }} <small>%</small></span
|
||||||
>
|
>
|
||||||
</ev-card>
|
<div
|
||||||
|
class="progress-bar"
|
||||||
<ev-card class="card">
|
role="progressbar"
|
||||||
<span class="card__label">Charge moyenne du parc</span>
|
aria-valuemin="0"
|
||||||
<span class="card__value">{{ s.average_load_percent }} %</span>
|
aria-valuemax="100"
|
||||||
<div class="progress-bar">
|
[attr.aria-valuenow]="s.average_load_percent"
|
||||||
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
|
>
|
||||||
|
<div
|
||||||
|
class="progress-bar__fill"
|
||||||
|
[class]="'progress-bar__fill--' + loadTone(s.average_load_percent)"
|
||||||
|
[style.width.%]="s.average_load_percent"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="kpi__hint">{{ loadHint(s.average_load_percent) }}</span>
|
||||||
</ev-card>
|
</ev-card>
|
||||||
|
|
||||||
<ev-card class="card">
|
<ev-card class="kpi">
|
||||||
<span class="card__label">Sites suivis</span>
|
<span class="kpi__label">Sites suivis</span>
|
||||||
<span class="card__value">{{ s.total_sites }}</span>
|
<span class="kpi__value">{{ s.total_sites }}</span>
|
||||||
|
<a routerLink="/sites" class="ev-link kpi__link">Voir la liste des sites</a>
|
||||||
</ev-card>
|
</ev-card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="chart-section">
|
|
||||||
<h2>Charge et alerte visuelle par site</h2>
|
|
||||||
<app-site-load-chart [sites]="s.sites" />
|
|
||||||
</section>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (alerts().length > 0) {
|
<div class="dashboard__grid">
|
||||||
<section class="alerts-section">
|
<div class="dashboard__main">
|
||||||
<h2>Alertes actives</h2>
|
@if (stats(); as s) {
|
||||||
<ul class="alerts-list">
|
<section class="chart-section">
|
||||||
@for (alert of alerts(); track alert.alert_id) {
|
<h2>Charge par site</h2>
|
||||||
<li class="alert-item">
|
<ev-card class="chart-card">
|
||||||
<ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
|
<app-site-load-chart [sites]="s.sites" />
|
||||||
<span class="alert-item__message">{{ alert.message }}</span>
|
</ev-card>
|
||||||
</li>
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="predictions-section">
|
||||||
|
<h2>Prévisions de consommation</h2>
|
||||||
|
@if (predictions().length > 0) {
|
||||||
|
<ev-card class="ev-table-card">
|
||||||
|
<table class="ev-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Prévision</th>
|
||||||
|
<th>Échéance</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (site of predictions(); track site.site_id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ site.site_name }}</td>
|
||||||
|
@if (site.prediction; as prediction) {
|
||||||
|
@if (prediction.status === 'available') {
|
||||||
|
<td class="ev-table__number">
|
||||||
|
{{ prediction.predicted_value | number: '1.0-1' }} kWh
|
||||||
|
</td>
|
||||||
|
<td>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</td>
|
||||||
|
} @else {
|
||||||
|
<td>
|
||||||
|
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
|
||||||
|
prediction.status === 'insufficient_data'
|
||||||
|
? 'Historique insuffisant'
|
||||||
|
: 'Erreur'
|
||||||
|
}}</ev-badge>
|
||||||
|
</td>
|
||||||
|
<td class="ev-table__muted">-</td>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<td><ev-badge tone="neutral">Pas encore de prévision</ev-badge></td>
|
||||||
|
<td class="ev-table__muted">-</td>
|
||||||
|
}
|
||||||
|
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ev-card>
|
||||||
|
} @else if (!predictionsError()) {
|
||||||
|
<p class="dashboard__empty">Aucune prévision disponible pour le moment.</p>
|
||||||
}
|
}
|
||||||
</ul>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (predictions().length > 0) {
|
<aside class="dashboard__side" aria-label="Alertes actives">
|
||||||
<section class="predictions-section">
|
<app-alert-feed />
|
||||||
<h2>Prévisions de consommation</h2>
|
</aside>
|
||||||
<ul class="predictions-list">
|
</div>
|
||||||
@for (site of predictions(); track site.site_id) {
|
|
||||||
<li class="prediction-item">
|
|
||||||
<span class="prediction-item__site">{{ site.site_name }}</span>
|
|
||||||
@if (site.prediction; as prediction) {
|
|
||||||
@if (prediction.status === 'available') {
|
|
||||||
<span class="prediction-item__value">
|
|
||||||
{{ prediction.predicted_value | number: '1.0-1' }} kWh
|
|
||||||
<span class="prediction-item__target"
|
|
||||||
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
|
|
||||||
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur'
|
|
||||||
}}</ev-badge>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<ev-badge tone="neutral">Pas encore de prévision</ev-badge>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
|
|
||||||
.dashboard__header {
|
.dashboard__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 2rem;
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__brand {
|
.dashboard__brand {
|
||||||
@@ -20,8 +22,9 @@
|
|||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.75rem;
|
font-size: var(--font-size-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,19 +34,69 @@
|
|||||||
|
|
||||||
.dashboard__subtitle {
|
.dashboard__subtitle {
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__actions {
|
.dashboard__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
.ev-link {
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__pulse {
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-success);
|
||||||
|
animation: pulse 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(22, 163, 74, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dashboard__pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.1rem;
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-error {
|
.banner-error {
|
||||||
@@ -54,107 +107,124 @@ h2 {
|
|||||||
.overview {
|
.overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 1rem;
|
gap: var(--space-3);
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.kpi {
|
||||||
padding: 1.25rem;
|
position: relative;
|
||||||
gap: 0.35rem;
|
overflow: hidden;
|
||||||
|
padding: var(--space-4);
|
||||||
|
gap: var(--space-1);
|
||||||
|
transition:
|
||||||
|
box-shadow 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card--gauge {
|
.kpi--gauge {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__label {
|
.kpi__label {
|
||||||
font-size: 0.8rem;
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__value {
|
.kpi__value {
|
||||||
font-size: 1.6rem;
|
font-size: var(--font-size-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi__hint {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi__link {
|
||||||
|
margin-top: auto;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 6px;
|
height: 8px;
|
||||||
|
margin: var(--space-1) 0;
|
||||||
background: var(--color-border-light);
|
background: var(--color-border-light);
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar__fill {
|
.progress-bar__fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-primary);
|
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-success);
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-bar__fill--warning {
|
||||||
|
background: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar__fill--danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__side {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.chart-section {
|
.chart-section {
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alerts-list {
|
.chart-card {
|
||||||
list-style: none;
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
font-size: var(--font-size-sm);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-danger-bg);
|
|
||||||
border: 1px solid var(--color-danger-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item__message {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.predictions-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item__site {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item__value {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item__target {
|
|
||||||
margin-left: 0.35rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.dashboard__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__side {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { of, throwError } from 'rxjs';
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
|
import { Router, provideRouter } from '@angular/router';
|
||||||
import { Dashboard } from './dashboard';
|
import { Dashboard } from './dashboard';
|
||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
import { AlertsService } from '../../core/services/alerts.service';
|
import { AlertsService } from '../../core/services/alerts.service';
|
||||||
|
import { SitesService } from '../../core/services/sites.service';
|
||||||
import { PredictionsService } from '../../core/services/predictions.service';
|
import { PredictionsService } from '../../core/services/predictions.service';
|
||||||
import {AuthService} from '../../core/services/auth.service';
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
import {Router, provideRouter} from '@angular/router';
|
|
||||||
|
|
||||||
vi.mock('chart.js', () => {
|
vi.mock('chart.js', () => {
|
||||||
class ChartMock {
|
class ChartMock {
|
||||||
@@ -18,66 +19,73 @@ vi.mock('chart.js', () => {
|
|||||||
return { Chart: ChartMock, registerables: [] };
|
return { Chart: ChartMock, registerables: [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const STATS = { total_sites: 7, sites: [] };
|
||||||
|
|
||||||
function predictionsMock(sites: unknown[] = []) {
|
function predictionsMock(sites: unknown[] = []) {
|
||||||
return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
|
return {
|
||||||
|
getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
options: {
|
||||||
|
stats?: Observable<unknown>;
|
||||||
|
predictions?: { getPredictions: ReturnType<typeof vi.fn> };
|
||||||
|
auth?: Record<string, unknown>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const statsMock = { getSummary: vi.fn().mockReturnValue(options.stats ?? of(STATS)) };
|
||||||
|
const predictions = options.predictions ?? predictionsMock();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [Dashboard],
|
||||||
|
providers: [
|
||||||
|
{ provide: StatsService, useValue: statsMock },
|
||||||
|
{ provide: AlertsService, useValue: { getAlerts: vi.fn().mockReturnValue(of([])) } },
|
||||||
|
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of([])) } },
|
||||||
|
{ provide: PredictionsService, useValue: predictions },
|
||||||
|
...(options.auth ? [{ provide: AuthService, useValue: options.auth }] : []),
|
||||||
|
provideRouter([]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return { fixture: TestBed.createComponent(Dashboard), statsMock, predictions };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Dashboard', () => {
|
describe('Dashboard', () => {
|
||||||
afterEach(() => vi.useRealTimers());
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
it('charge les stats, les alertes et les prévisions au démarrage', async () => {
|
it('charge les stats et les prévisions au démarrage', async () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture, statsMock, predictions } = setup({
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
|
predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]),
|
||||||
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictions },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
// laisse le timer(0, ...) se déclencher avant de vérifier
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(statsMock.getSummary).toHaveBeenCalled();
|
expect(statsMock.getSummary).toHaveBeenCalled();
|
||||||
expect(alertsMock.getAlerts).toHaveBeenCalled();
|
|
||||||
expect(predictions.getPredictions).toHaveBeenCalled();
|
expect(predictions.getPredictions).toHaveBeenCalled();
|
||||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
|
||||||
expect(fixture.componentInstance.predictions().length).toBe(1);
|
expect(fixture.componentInstance.predictions().length).toBe(1);
|
||||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||||
expect(fixture.componentInstance.alertsError()).toBeNull();
|
|
||||||
expect(fixture.componentInstance.predictionsError()).toBeNull();
|
expect(fixture.componentInstance.predictionsError()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('délègue les alertes au widget app-alert-feed', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('app-alert-feed')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const statsMock = {
|
const { fixture, statsMock } = setup({
|
||||||
getSummary: vi
|
stats: throwError(() => new Error('API injoignable')),
|
||||||
.fn()
|
|
||||||
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
|
||||||
.mockReturnValue(of({ total_sites: 7, sites: [] })),
|
|
||||||
};
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
statsMock.getSummary
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||||
|
.mockReturnValue(of(STATS));
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
vi.advanceTimersByTime(1);
|
vi.advanceTimersByTime(1);
|
||||||
@@ -91,45 +99,11 @@ describe('Dashboard', () => {
|
|||||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
|
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.componentInstance.alerts().length).toBe(0);
|
|
||||||
expect(fixture.componentInstance.alertsError()).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
|
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture } = setup({
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
const predictions = {
|
|
||||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
|
||||||
};
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictions },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.componentInstance.predictions().length).toBe(0);
|
expect(fixture.componentInstance.predictions().length).toBe(0);
|
||||||
@@ -138,25 +112,11 @@ describe('Dashboard', () => {
|
|||||||
|
|
||||||
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
|
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture } = setup({
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
const predictions = {
|
|
||||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
|
||||||
};
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictions },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
||||||
|
|
||||||
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
|
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
|
||||||
@@ -168,105 +128,106 @@ 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 authMock = {
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
logout: vi.fn().mockReturnValue(of(undefined)),
|
||||||
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
|
clearSession: vi.fn(),
|
||||||
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
|
};
|
||||||
|
const { fixture } = setup({ auth: authMock });
|
||||||
|
fixture.detectChanges();
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
fixture.nativeElement.querySelector('.logout-button').click();
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
expect(authMock.logout).toHaveBeenCalled();
|
||||||
{ provide: StatsService, useValue: statsMock },
|
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
{ provide: AuthService, useValue: authMock },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const router = TestBed.inject(Router);
|
|
||||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
|
||||||
button.click();
|
|
||||||
|
|
||||||
expect(authMock.logout).toHaveBeenCalled();
|
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
|
||||||
});
|
|
||||||
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const authMock = {
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||||
const authMock = {
|
clearSession: vi.fn(),
|
||||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
clearSession: vi.fn(),
|
};
|
||||||
};
|
const { fixture } = setup({ auth: authMock });
|
||||||
TestBed.configureTestingModule({
|
fixture.detectChanges();
|
||||||
imports: [Dashboard],
|
const router = TestBed.inject(Router);
|
||||||
providers: [
|
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
fixture.nativeElement.querySelector('.logout-button').click();
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
{ provide: AuthService, useValue: authMock },
|
expect(authMock.clearSession).toHaveBeenCalled();
|
||||||
provideRouter([]),
|
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
it('affiche l’heure du dernier relevé et les indicateurs du parc', () => {
|
||||||
fixture.detectChanges();
|
vi.useFakeTimers();
|
||||||
|
const { fixture } = setup({
|
||||||
const router = TestBed.inject(Router);
|
stats: of({
|
||||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
timestamp: '2026-09-18T09:00:00Z',
|
||||||
|
total_sites: 7,
|
||||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
total_consumption_kw: 1234.5,
|
||||||
button.click();
|
total_capacity_kw: 5000,
|
||||||
|
average_load_percent: 24.7,
|
||||||
expect(authMock.clearSession).toHaveBeenCalled();
|
sites: [],
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
}),
|
||||||
});
|
|
||||||
|
|
||||||
it('distingue le ton des sévérités high et critical', () => {
|
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
fixture.detectChanges();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const texte = fixture.nativeElement.textContent as string;
|
||||||
|
expect(texte).toContain('Actualisé à');
|
||||||
|
expect(texte).toContain('7 sites suivis');
|
||||||
|
expect(texte).toContain('Marge confortable');
|
||||||
|
expect(fixture.nativeElement.querySelector('.progress-bar__fill--success')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('présente les prévisions en tableau avec un lien vers chaque site', () => {
|
||||||
|
const { fixture } = setup({
|
||||||
|
predictions: predictionsMock([
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
prediction: {
|
||||||
|
target_at: '2026-09-18T10:00:00Z',
|
||||||
|
target_metric: 'consumption_kwh',
|
||||||
|
period_minutes: 60,
|
||||||
|
predicted_value: 118.4,
|
||||||
|
status: 'available',
|
||||||
|
failure_reason: null,
|
||||||
|
model_reference: 'lightgbm-v1',
|
||||||
|
created_at: '2026-09-18T09:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ site_id: 'SITE002', site_name: 'Bureau Lille', prediction: null },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const table = fixture.nativeElement.querySelector('table.ev-table');
|
||||||
|
expect(table).not.toBeNull();
|
||||||
|
expect(table.textContent).toContain('118.4 kWh');
|
||||||
|
expect(table.textContent).toContain('Pas encore de prévision');
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE001"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colore la charge moyenne selon les seuils 70 % et 90 %', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
const dashboard = fixture.componentInstance;
|
const dashboard = fixture.componentInstance;
|
||||||
|
|
||||||
expect(dashboard.badgeToneForSeverity('low')).toBe('success');
|
expect(dashboard.loadTone(69.9)).toBe('success');
|
||||||
expect(dashboard.badgeToneForSeverity('medium')).toBe('warning');
|
expect(dashboard.loadTone(70)).toBe('warning');
|
||||||
expect(dashboard.badgeToneForSeverity('high')).toBe('danger');
|
expect(dashboard.loadTone(89.9)).toBe('warning');
|
||||||
expect(dashboard.badgeToneForSeverity('critical')).toBe('critical');
|
expect(dashboard.loadTone(90)).toBe('danger');
|
||||||
expect(dashboard.badgeToneForSeverity('high')).not.toBe(
|
expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc');
|
||||||
dashboard.badgeToneForSeverity('critical'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('distingue le ton des statuts de prévision', () => {
|
it('distingue le ton des statuts de prévision', () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture } = setup();
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
const dashboard = fixture.componentInstance;
|
const dashboard = fixture.componentInstance;
|
||||||
|
|
||||||
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
|
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import { Router, RouterLink } from '@angular/router';
|
|||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
||||||
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
||||||
import { AlertsService } from '../../core/services/alerts.service';
|
import { AlertFeed } from '../../shared/components/alert-feed/alert-feed';
|
||||||
import { PredictionsService } from '../../core/services/predictions.service';
|
import { PredictionsService } from '../../core/services/predictions.service';
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
import { StatsSummary } from '../../shared/models/stats.model';
|
import { StatsSummary } from '../../shared/models/stats.model';
|
||||||
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
|
||||||
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
|
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
|
||||||
import { Card } from '../../shared/components/ui/card/card';
|
import { Card } from '../../shared/components/ui/card/card';
|
||||||
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||||
@@ -22,22 +21,19 @@ const REFRESH_INTERVAL_MS = 10000;
|
|||||||
const UNAVAILABLE_MESSAGE =
|
const UNAVAILABLE_MESSAGE =
|
||||||
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
||||||
|
|
||||||
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
// `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` :
|
||||||
low: 'success',
|
// mieux vaut un ton défini que `undefined` le jour où ce statut apparaît.
|
||||||
medium: 'warning',
|
|
||||||
high: 'danger',
|
|
||||||
critical: 'critical',
|
|
||||||
};
|
|
||||||
|
|
||||||
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
|
|
||||||
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
|
|
||||||
// tomber sur `undefined` si ce statut apparaît un jour.
|
|
||||||
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
||||||
available: 'success',
|
available: 'success',
|
||||||
insufficient_data: 'warning',
|
insufficient_data: 'warning',
|
||||||
error: 'danger',
|
error: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SEUIL_CHARGE_SOUTENUE = 70;
|
||||||
|
const SEUIL_CHARGE_CRITIQUE = 90;
|
||||||
|
|
||||||
|
export type LoadTone = 'success' | 'warning' | 'danger';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -47,6 +43,7 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
|||||||
RouterLink,
|
RouterLink,
|
||||||
ConsumptionGauge,
|
ConsumptionGauge,
|
||||||
SiteLoadChart,
|
SiteLoadChart,
|
||||||
|
AlertFeed,
|
||||||
Card,
|
Card,
|
||||||
EvAlert,
|
EvAlert,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -58,32 +55,20 @@ 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);
|
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);
|
||||||
|
|
||||||
stats = signal<StatsSummary | null>(null);
|
stats = signal<StatsSummary | null>(null);
|
||||||
alerts = signal<Alert[]>([]);
|
|
||||||
predictions = signal<SitePredictionSummary[]>([]);
|
predictions = signal<SitePredictionSummary[]>([]);
|
||||||
|
|
||||||
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
|
// Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en
|
||||||
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
|
// silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur.
|
||||||
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
|
|
||||||
statsError = signal<string | null>(null);
|
statsError = signal<string | null>(null);
|
||||||
alertsError = signal<string | null>(null);
|
|
||||||
predictionsError = signal<string | null>(null);
|
predictionsError = signal<string | null>(null);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.alertsService
|
|
||||||
.getAlerts()
|
|
||||||
.pipe(catchError(() => this.reportUnavailable(this.alertsError)))
|
|
||||||
.subscribe((alerts) => {
|
|
||||||
this.alertsError.set(null);
|
|
||||||
this.alerts.set(alerts);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
|
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
|
||||||
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
|
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
|
||||||
this.predictionsService
|
this.predictionsService
|
||||||
@@ -99,7 +84,9 @@ export class Dashboard implements OnInit {
|
|||||||
timer(0, REFRESH_INTERVAL_MS)
|
timer(0, REFRESH_INTERVAL_MS)
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(() =>
|
switchMap(() =>
|
||||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
this.statsService
|
||||||
|
.getSummary()
|
||||||
|
.pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
||||||
),
|
),
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
)
|
)
|
||||||
@@ -109,14 +96,28 @@ export class Dashboard implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
|
|
||||||
return TON_PAR_SEVERITE[severity];
|
|
||||||
}
|
|
||||||
|
|
||||||
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
|
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
|
||||||
return TON_PAR_STATUT_PREDICTION[status];
|
return TON_PAR_STATUT_PREDICTION[status];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadTone(percent: number): LoadTone {
|
||||||
|
if (percent >= SEUIL_CHARGE_CRITIQUE) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
return percent >= SEUIL_CHARGE_SOUTENUE ? 'warning' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHint(percent: number): string {
|
||||||
|
switch (this.loadTone(percent)) {
|
||||||
|
case 'danger':
|
||||||
|
return 'Proche de la capacité du parc';
|
||||||
|
case 'warning':
|
||||||
|
return 'Charge soutenue';
|
||||||
|
default:
|
||||||
|
return 'Marge confortable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onLogout(): void {
|
onLogout(): void {
|
||||||
this.auth.logout().subscribe({
|
this.auth.logout().subscribe({
|
||||||
next: () => this.router.navigate(['/login']),
|
next: () => this.router.navigate(['/login']),
|
||||||
|
|||||||
@@ -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,58 @@
|
|||||||
|
<div class="recommendations">
|
||||||
|
<nav class="ev-breadcrumb">
|
||||||
|
<a routerLink="/dashboard">Tableau de bord</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="recommendations__header">
|
||||||
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="recommendations__logo" />
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1>Recommandations</h1>
|
||||||
|
<p class="recommendations__subtitle">
|
||||||
|
Actions proposées par le moteur de règles à partir des alertes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="recommendations__toolbar">
|
||||||
|
<label class="recommendations__filter">
|
||||||
|
<span class="form-label">Site</span>
|
||||||
|
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
|
||||||
|
<option value="" [selected]="!siteFilter()">Tous les sites</option>
|
||||||
|
@for (site of sites(); track site.site_id) {
|
||||||
|
<option [value]="site.site_id" [selected]="site.site_id === siteFilter()">
|
||||||
|
{{ site.site_name }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
@if (isAdmin()) {
|
||||||
|
<ev-button
|
||||||
|
variant="secondary"
|
||||||
|
[fullWidth]="false"
|
||||||
|
[disabled]="generating()"
|
||||||
|
data-testid="generate"
|
||||||
|
(click)="onGenerate()"
|
||||||
|
>
|
||||||
|
{{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }}
|
||||||
|
</ev-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (generationReport(); as report) {
|
||||||
|
<ev-alert severity="success" class="recommendations__banner">{{ bilan(report) }}.</ev-alert>
|
||||||
|
}
|
||||||
|
@if (generationError(); as message) {
|
||||||
|
<ev-alert severity="danger" class="recommendations__banner">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (alertId(); as id) {
|
||||||
|
<p class="recommendations__focus">
|
||||||
|
Alerte n° {{ id }} ·
|
||||||
|
<a routerLink="/recommendations" class="ev-link">Toutes les recommandations</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-recommendation-list [siteId]="siteFilter()" [alertId]="alertId()" [sites]="sites()" />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__logo {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 14rem;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__focus {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||||
|
import { RecommendationsView, parseAlertId } from './recommendations';
|
||||||
|
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
|
||||||
|
import { SitesService } from '../../core/services/sites.service';
|
||||||
|
import { AlertsService } from '../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../core/services/recommendations.service';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
|
||||||
|
const SITES = [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BILAN = { alerts_examined: 2, recommendations_created: 3, already_present: 1 };
|
||||||
|
|
||||||
|
function setup(options: { query?: Record<string, string>; role?: string } = {}) {
|
||||||
|
const query = options.query ?? {};
|
||||||
|
const queryParamMap = new BehaviorSubject(convertToParamMap(query));
|
||||||
|
const generate = vi.fn().mockReturnValue(of(BILAN));
|
||||||
|
const getRecommendations = vi.fn().mockReturnValue(of([]));
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [RecommendationsView],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: { queryParamMap, snapshot: { queryParamMap: convertToParamMap(query) } },
|
||||||
|
},
|
||||||
|
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of(SITES)) } },
|
||||||
|
{ provide: AlertsService, useValue: { getAlerts } },
|
||||||
|
{ provide: RecommendationsService, useValue: { getRecommendations, generate } },
|
||||||
|
{
|
||||||
|
provide: AuthService,
|
||||||
|
useValue: { principal: vi.fn().mockReturnValue({ role: options.role ?? 'lecteur' }) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(RecommendationsView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
return { fixture, queryParamMap, generate, getRecommendations, getAlerts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listeEnfant(fixture: ReturnType<typeof setup>['fixture']): RecommendationList {
|
||||||
|
return fixture.debugElement.query(By.directive(RecommendationList)).componentInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseAlertId', () => {
|
||||||
|
it("n'accepte qu'un entier strictement positif", () => {
|
||||||
|
expect(parseAlertId('12')).toBe(12);
|
||||||
|
expect(parseAlertId('0')).toBeNull();
|
||||||
|
expect(parseAlertId('-3')).toBeNull();
|
||||||
|
expect(parseAlertId('abc')).toBeNull();
|
||||||
|
expect(parseAlertId('12abc')).toBeNull();
|
||||||
|
expect(parseAlertId(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecommendationsView', () => {
|
||||||
|
it("cible l'alerte donnée par ?alert= et la transmet à la liste", () => {
|
||||||
|
const { fixture } = setup({ query: { alert: '12' } });
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.alertId()).toBe(12);
|
||||||
|
expect(listeEnfant(fixture).alertId()).toBe(12);
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Alerte n° 12');
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href="/recommendations"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore un paramètre alert invalide', () => {
|
||||||
|
const { fixture } = setup({ query: { alert: 'abc' } });
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.alertId()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Alerte n°');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applique le site donné par ?site= au filtre et à la liste', () => {
|
||||||
|
const { fixture, getAlerts } = setup({ query: { site: 'SITE001' } });
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||||
|
const option = fixture.nativeElement.querySelector(
|
||||||
|
'option[value="SITE001"]',
|
||||||
|
) as HTMLOptionElement;
|
||||||
|
expect(option.selected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relance la liste sur le site choisi dans le filtre', () => {
|
||||||
|
const { fixture, getAlerts } = setup();
|
||||||
|
const select = fixture.nativeElement.querySelector(
|
||||||
|
'[data-testid="site-filter"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
|
||||||
|
select.value = 'SITE001';
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenLastCalledWith({ site_id: 'SITE001' });
|
||||||
|
expect(listeEnfant(fixture).siteId()).toBe('SITE001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cache le bouton de génération aux lecteurs', () => {
|
||||||
|
const { fixture } = setup({ role: 'lecteur' });
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="generate"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permet à un admin de générer pour le site filtré, affiche le bilan et recharge la liste', () => {
|
||||||
|
const { fixture, generate, getRecommendations } = setup({
|
||||||
|
role: 'admin',
|
||||||
|
query: { site: 'SITE001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('[data-testid="generate"]').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(generate).toHaveBeenCalledWith('SITE001');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'3 recommandations créées, 1 déjà présente, 2 alertes examinées.',
|
||||||
|
);
|
||||||
|
expect(getRecommendations).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.componentInstance.generating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('génère pour tout le parc quand aucun site n’est filtré', () => {
|
||||||
|
const { fixture, generate } = setup({ role: 'admin' });
|
||||||
|
|
||||||
|
fixture.componentInstance.onGenerate();
|
||||||
|
|
||||||
|
expect(generate).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'échec de la génération sans casser la page", () => {
|
||||||
|
const { fixture, generate } = setup({ role: 'admin' });
|
||||||
|
generate.mockReturnValue(throwError(() => new Error('403')));
|
||||||
|
|
||||||
|
fixture.componentInstance.onGenerate();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.generationError()).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'La génération des recommandations a échoué',
|
||||||
|
);
|
||||||
|
expect(fixture.componentInstance.generating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accorde le bilan au singulier', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.componentInstance.bilan({
|
||||||
|
alerts_examined: 1,
|
||||||
|
recommendations_created: 1,
|
||||||
|
already_present: 0,
|
||||||
|
}),
|
||||||
|
).toBe('1 recommandation créée, 0 déjà présente, 1 alerte examinée');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Component, computed, inject, signal, viewChild } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { catchError, map, of } from 'rxjs';
|
||||||
|
import { SitesService } from '../../core/services/sites.service';
|
||||||
|
import { RecommendationsService } from '../../core/services/recommendations.service';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { Site } from '../../shared/models/site.model';
|
||||||
|
import { RecommendationGenerationReport } from '../../shared/models/recommendation.model';
|
||||||
|
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
|
||||||
|
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||||
|
import { Brand } from '../../shared/components/ui/brand/brand';
|
||||||
|
import { Button } from '../../shared/components/ui/button/button';
|
||||||
|
|
||||||
|
const GENERATION_FAILED_MESSAGE =
|
||||||
|
'La génération des recommandations a échoué, réessayez plus tard.';
|
||||||
|
|
||||||
|
export function parseAlertId(raw: string | null): number | null {
|
||||||
|
return raw !== null && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluriel(nombre: number, singulier: string, plurielForme: string): string {
|
||||||
|
return `${nombre} ${nombre > 1 ? plurielForme : singulier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-recommendations',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, RecommendationList, EvAlert, Brand, Button],
|
||||||
|
templateUrl: './recommendations.html',
|
||||||
|
styleUrl: './recommendations.scss',
|
||||||
|
})
|
||||||
|
export class RecommendationsView {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private sitesService = inject(SitesService);
|
||||||
|
private recommendationsService = inject(RecommendationsService);
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
|
||||||
|
alertId = toSignal(
|
||||||
|
this.route.queryParamMap.pipe(map((params) => parseAlertId(params.get('alert')))),
|
||||||
|
{ initialValue: null },
|
||||||
|
);
|
||||||
|
siteFilter = signal<string | null>(this.route.snapshot.queryParamMap.get('site'));
|
||||||
|
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
|
||||||
|
initialValue: [] as Site[],
|
||||||
|
});
|
||||||
|
|
||||||
|
list = viewChild.required(RecommendationList);
|
||||||
|
|
||||||
|
isAdmin = computed(() => this.auth.principal()?.role === 'admin');
|
||||||
|
generating = signal(false);
|
||||||
|
generationReport = signal<RecommendationGenerationReport | null>(null);
|
||||||
|
generationError = signal<string | null>(null);
|
||||||
|
|
||||||
|
onSiteChange(event: Event): void {
|
||||||
|
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onGenerate(): void {
|
||||||
|
if (this.generating()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.generating.set(true);
|
||||||
|
this.generationError.set(null);
|
||||||
|
this.recommendationsService.generate(this.siteFilter() ?? undefined).subscribe({
|
||||||
|
next: (report) => {
|
||||||
|
this.generating.set(false);
|
||||||
|
this.generationReport.set(report);
|
||||||
|
this.list().reload();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.generating.set(false);
|
||||||
|
this.generationError.set(GENERATION_FAILED_MESSAGE);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bilan(report: RecommendationGenerationReport): string {
|
||||||
|
return [
|
||||||
|
pluriel(report.recommendations_created, 'recommandation créée', 'recommandations créées'),
|
||||||
|
pluriel(report.already_present, 'déjà présente', 'déjà présentes'),
|
||||||
|
pluriel(report.alerts_examined, 'alerte examinée', 'alertes examinées'),
|
||||||
|
].join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,5 +81,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<section class="recommendations-section">
|
||||||
|
<h2>Recommandations</h2>
|
||||||
|
<app-recommendation-list [siteId]="siteId() ?? null" [sites]="siteAsList()" />
|
||||||
|
<a
|
||||||
|
routerLink="/recommendations"
|
||||||
|
[queryParams]="{ site: siteId() }"
|
||||||
|
class="ev-link recommendations-section__link"
|
||||||
|
>Voir dans la vue recommandations</a
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,3 +117,12 @@ h2 {
|
|||||||
.chart-section {
|
.chart-section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recommendations-section {
|
||||||
|
margin: 2.5rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations-section__link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { BehaviorSubject, of, throwError } from 'rxjs';
|
|||||||
import { SiteDetail } from './site-detail';
|
import { SiteDetail } from './site-detail';
|
||||||
import { SitesService } from '../../../core/services/sites.service';
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
import { ReadingsService } from '../../../core/services/readings.service';
|
import { ReadingsService } from '../../../core/services/readings.service';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||||
|
|
||||||
const SITE = {
|
const SITE = {
|
||||||
site_id: 'SITE001',
|
site_id: 'SITE001',
|
||||||
@@ -69,6 +71,7 @@ function setup(
|
|||||||
readingsMock: Partial<ReadingsService>,
|
readingsMock: Partial<ReadingsService>,
|
||||||
) {
|
) {
|
||||||
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
|
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [SiteDetail],
|
imports: [SiteDetail],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -76,9 +79,14 @@ function setup(
|
|||||||
{ provide: ActivatedRoute, useValue: { paramMap } },
|
{ provide: ActivatedRoute, useValue: { paramMap } },
|
||||||
{ provide: SitesService, useValue: sitesMock },
|
{ provide: SitesService, useValue: sitesMock },
|
||||||
{ provide: ReadingsService, useValue: readingsMock },
|
{ provide: ReadingsService, useValue: readingsMock },
|
||||||
|
{ provide: AlertsService, useValue: { getAlerts } },
|
||||||
|
{
|
||||||
|
provide: RecommendationsService,
|
||||||
|
useValue: { getRecommendations: vi.fn().mockReturnValue(of([])) },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
|
return { fixture: TestBed.createComponent(SiteDetail), paramMap, getAlerts };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('SiteDetail', () => {
|
describe('SiteDetail', () => {
|
||||||
@@ -261,6 +269,27 @@ describe('SiteDetail', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('demande les recommandations du site consulté à travers ses alertes', () => {
|
||||||
|
const { fixture, getAlerts } = setup(
|
||||||
|
'SITE001',
|
||||||
|
{
|
||||||
|
getSite: vi.fn().mockReturnValue(of(SITE)),
|
||||||
|
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
|
||||||
|
},
|
||||||
|
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||||
|
expect(fixture.nativeElement.querySelector('app-recommendation-list')).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Recommandations');
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('a[href="/recommendations?site=SITE001"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
|
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
|
||||||
const getHistory = vi.fn().mockReturnValue(of([]));
|
const getHistory = vi.fn().mockReturnValue(of([]));
|
||||||
const { fixture } = setup(
|
const { fixture } = setup(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
|
|||||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||||
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
|
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
|
||||||
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
||||||
|
import { RecommendationList } from '../../../shared/components/recommendation-list/recommendation-list';
|
||||||
|
|
||||||
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
||||||
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
||||||
@@ -96,7 +97,16 @@ export interface MetricView {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-site-detail',
|
selector: 'app-site-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
Card,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Brand,
|
||||||
|
ConsumptionGauge,
|
||||||
|
ReadingHistoryChart,
|
||||||
|
RecommendationList,
|
||||||
|
],
|
||||||
templateUrl: './site-detail.html',
|
templateUrl: './site-detail.html',
|
||||||
styleUrl: './site-detail.scss',
|
styleUrl: './site-detail.scss',
|
||||||
})
|
})
|
||||||
@@ -117,6 +127,11 @@ export class SiteDetail {
|
|||||||
|
|
||||||
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
||||||
|
|
||||||
|
siteAsList = computed<Site[]>(() => {
|
||||||
|
const site = this.site();
|
||||||
|
return site ? [site] : [];
|
||||||
|
});
|
||||||
|
|
||||||
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
|
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
|
||||||
|
|
||||||
consumptionLabel = computed(() => {
|
consumptionLabel = computed(() => {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
}
|
}
|
||||||
|
|
||||||
<ev-card class="table-card">
|
<ev-card class="ev-table-card">
|
||||||
<table class="sites-table">
|
<table class="ev-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
@@ -36,7 +36,9 @@
|
|||||||
<td>{{ site.site_type }}</td>
|
<td>{{ site.site_type }}</td>
|
||||||
<td>{{ site.location || '-' }}</td>
|
<td>{{ site.location || '-' }}</td>
|
||||||
<td>{{ site.capacity_kw ?? '-' }}</td>
|
<td>{{ site.capacity_kw ?? '-' }}</td>
|
||||||
<td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td>
|
<td>
|
||||||
|
<ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge>
|
||||||
|
</td>
|
||||||
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,32 +32,3 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-card {
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sites-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 0.85rem 1.25rem;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<section class="alert-feed">
|
||||||
|
<header class="alert-feed__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="alert-feed__title">Alertes actives</h2>
|
||||||
|
@if (!loading() || alerts().length > 0) {
|
||||||
|
<p class="alert-feed__count">
|
||||||
|
{{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="alert-feed__filters">
|
||||||
|
@if (!siteId()) {
|
||||||
|
<label class="alert-feed__filter">
|
||||||
|
<span class="form-label">Site</span>
|
||||||
|
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
|
||||||
|
<option value="">Tous les sites</option>
|
||||||
|
@for (site of sites(); track site.site_id) {
|
||||||
|
<option [value]="site.site_id">{{ site.site_name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="alert-feed__filter">
|
||||||
|
<span class="form-label">Sévérité</span>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
data-testid="severity-filter"
|
||||||
|
(change)="onSeverityChange($event)"
|
||||||
|
>
|
||||||
|
<option value="">Toutes</option>
|
||||||
|
@for (severite of severites; track severite) {
|
||||||
|
<option [value]="severite">{{ severityLabel(severite) }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="alert-feed__banner">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading() && alerts().length === 0 && !error()) {
|
||||||
|
<p class="alert-feed__state" aria-live="polite">Chargement des alertes…</p>
|
||||||
|
} @else if (alerts().length === 0 && !error()) {
|
||||||
|
<ev-alert severity="success" class="alert-feed__banner"
|
||||||
|
>Aucune alerte pour ces critères.</ev-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ul class="alert-feed__list" [attr.aria-busy]="loading()">
|
||||||
|
@for (alert of visibleAlerts(); track alert.alert_id) {
|
||||||
|
<li class="alert-feed__item" [class]="'alert-feed__item--' + alert.severity">
|
||||||
|
<span class="alert-feed__icon">
|
||||||
|
<ev-icon [name]="alert.type" [label]="typeLabel(alert.type)" />
|
||||||
|
</span>
|
||||||
|
<div class="alert-feed__body">
|
||||||
|
<div class="alert-feed__meta">
|
||||||
|
<ev-badge [tone]="toneFor(alert.severity)">{{
|
||||||
|
severityLabel(alert.severity)
|
||||||
|
}}</ev-badge>
|
||||||
|
<span class="alert-feed__type">{{ typeLabel(alert.type) }}</span>
|
||||||
|
<span class="alert-feed__site">{{ siteName(alert.site_id) }}</span>
|
||||||
|
<time class="alert-feed__time" [attr.datetime]="alert.timestamp">
|
||||||
|
{{ alert.timestamp | date: 'dd/MM/yyyy HH:mm' }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p class="alert-feed__message">{{ alert.message }}</p>
|
||||||
|
@if (alert.value !== null) {
|
||||||
|
<p class="alert-feed__values">
|
||||||
|
<strong>{{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }}</strong>
|
||||||
|
@if (alert.threshold !== null) {
|
||||||
|
<span
|
||||||
|
>seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (hiddenCount() > 0) {
|
||||||
|
<ev-button
|
||||||
|
variant="secondary"
|
||||||
|
[fullWidth]="false"
|
||||||
|
class="alert-feed__more"
|
||||||
|
data-testid="show-more"
|
||||||
|
(click)="showMore()"
|
||||||
|
>
|
||||||
|
Afficher plus ({{ hiddenCount() }} restantes)
|
||||||
|
</ev-button>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__count {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 10rem;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__state {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-left: 4px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item--medium {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
color: var(--color-warning-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item--high {
|
||||||
|
border-left-color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item--critical {
|
||||||
|
border-left-color: var(--color-critical);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
color: var(--color-critical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__values {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__more {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { AlertFeed } from './alert-feed';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
|
import { Alert } from '../../models/alert.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
|
||||||
|
const SITES: Site[] = [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function alerte(surcharges: Partial<Alert> = {}): Alert {
|
||||||
|
return {
|
||||||
|
alert_id: 1,
|
||||||
|
site_id: 'SITE001',
|
||||||
|
timestamp: '2026-09-15T11:12:00Z',
|
||||||
|
type: 'spike',
|
||||||
|
severity: 'critical',
|
||||||
|
message: 'Variation brutale entre deux lectures consécutives',
|
||||||
|
value: 812.5,
|
||||||
|
threshold: 400,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
|
||||||
|
sitesMock: { getSites: ReturnType<typeof vi.fn> } = {
|
||||||
|
getSites: vi.fn().mockReturnValue(of(SITES)),
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [AlertFeed],
|
||||||
|
providers: [
|
||||||
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
|
{ provide: SitesService, useValue: sitesMock },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return TestBed.createComponent(AlertFeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function premierChargement(fixture: ComponentFixture<AlertFeed>) {
|
||||||
|
fixture.detectChanges();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function texte(fixture: ComponentFixture<AlertFeed>): string {
|
||||||
|
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function choisir(fixture: ComponentFixture<AlertFeed>, testId: string, value: string) {
|
||||||
|
const select = fixture.nativeElement.querySelector(
|
||||||
|
`[data-testid="${testId}"]`,
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
select.value = value;
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AlertFeed', () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('charge les alertes au démarrage sans filtre et les affiche avec leur contexte', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getAlerts.mock.calls[0][0]).toEqual({});
|
||||||
|
const contenu = texte(fixture);
|
||||||
|
expect(contenu).toContain('Usine Nantes');
|
||||||
|
expect(contenu).toContain('Critique');
|
||||||
|
expect(contenu).toContain('Pic de consommation');
|
||||||
|
expect(contenu).toContain('15/09/2026');
|
||||||
|
expect(contenu).toContain('812.5 kW');
|
||||||
|
expect(contenu).toContain('seuil 400 kW');
|
||||||
|
expect(fixture.nativeElement.querySelector('ev-icon svg')).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('.alert-feed__item--critical')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annonce le chargement avant la première réponse', () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.loading()).toBe(true);
|
||||||
|
expect(texte(fixture)).toContain('Chargement des alertes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence d'alerte pour les critères choisis", () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('Aucune alerte pour ces critères.');
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relance la requête avec la sévérité choisie et revient à la première page', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
premierChargement(fixture);
|
||||||
|
fixture.componentInstance.showMore();
|
||||||
|
|
||||||
|
choisir(fixture, 'severity-filter', 'high');
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getAlerts.mock.calls[1][0]).toEqual({ severity: 'high' });
|
||||||
|
expect(fixture.componentInstance.visibleCount()).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relance la requête avec le site choisi dans le filtre', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
choisir(fixture, 'site-filter', 'SITE001');
|
||||||
|
|
||||||
|
expect(getAlerts.mock.calls[1][0]).toEqual({ site_id: 'SITE001' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('masque le filtre site et force site_id quand le parent fixe le site', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
fixture.componentRef.setInput('siteId', 'SITE001');
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts.mock.calls[0][0]).toEqual({ site_id: 'SITE001' });
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="site-filter"]')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="severity-filter"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'indisponibilité en gardant la liste, puis repart au rafraîchissement suivant", () => {
|
||||||
|
const getAlerts = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(of([alerte()]))
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||||
|
.mockReturnValue(of([alerte(), alerte({ alert_id: 2 })]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||||
|
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||||
|
expect(texte(fixture)).toContain('Alertes indisponibles');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(3);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
expect(fixture.componentInstance.alerts().length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pagine côté client par dix et dévoile le reste à la demande', () => {
|
||||||
|
const alertes = Array.from({ length: 25 }, (_, i) => alerte({ alert_id: i + 1 }));
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(alertes)) });
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(10);
|
||||||
|
expect(texte(fixture)).toContain('Afficher plus (15 restantes)');
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(20);
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(25);
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="show-more"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replie sur l'identifiant quand le site est inconnu ou que la liste des sites échoue", () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(of([alerte({ site_id: 'SITE999' })])) },
|
||||||
|
{ getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
|
);
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('SITE999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'affiche pas de mesure pour une alerte sans valeur", () => {
|
||||||
|
const fixture = setup({
|
||||||
|
getAlerts: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(
|
||||||
|
of([alerte({ type: 'outage', value: null, threshold: null, metric: null })]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('.alert-feed__values')).toBeNull();
|
||||||
|
expect(texte(fixture)).toContain('Coupure');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { catchError, EMPTY, Observable, of, switchMap, tap, timer } from 'rxjs';
|
||||||
|
import { AlertFilters, AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
|
import { Alert, AlertMetric, AlertSeverity, AlertType } from '../../models/alert.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
import {
|
||||||
|
LIBELLE_PAR_SEVERITE,
|
||||||
|
LIBELLE_PAR_TYPE,
|
||||||
|
SEVERITES,
|
||||||
|
TON_PAR_SEVERITE,
|
||||||
|
UNITE_PAR_METRIQUE,
|
||||||
|
} from '../../models/alert-presentation';
|
||||||
|
import { Badge, BadgeTone } from '../ui/badge/badge';
|
||||||
|
import { Button } from '../ui/button/button';
|
||||||
|
import { Alert as EvAlert } from '../ui/alert/alert';
|
||||||
|
import { Icon } from '../ui/icon/icon';
|
||||||
|
|
||||||
|
// Le DAG de détection tourne toutes les heures : une minute suffit largement pour suivre le flux.
|
||||||
|
const REFRESH_INTERVAL_MS = 60_000;
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
const UNAVAILABLE_MESSAGE = 'Alertes indisponibles, la liste affichée date du dernier chargement.';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-alert-feed',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DatePipe, DecimalPipe, Badge, Button, EvAlert, Icon],
|
||||||
|
templateUrl: './alert-feed.html',
|
||||||
|
styleUrl: './alert-feed.scss',
|
||||||
|
})
|
||||||
|
export class AlertFeed {
|
||||||
|
private alertsService = inject(AlertsService);
|
||||||
|
private sitesService = inject(SitesService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
siteId = input<string | null>(null);
|
||||||
|
|
||||||
|
readonly severites = SEVERITES;
|
||||||
|
severity = signal<AlertSeverity | null>(null);
|
||||||
|
siteFilter = signal<string | null>(null);
|
||||||
|
|
||||||
|
alerts = signal<Alert[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
visibleCount = signal(PAGE_SIZE);
|
||||||
|
|
||||||
|
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
|
||||||
|
initialValue: [] as Site[],
|
||||||
|
});
|
||||||
|
|
||||||
|
private filters = computed<AlertFilters>(() => ({
|
||||||
|
site_id: this.siteId() ?? this.siteFilter() ?? undefined,
|
||||||
|
severity: this.severity() ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
private siteNameById = computed(
|
||||||
|
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
|
||||||
|
);
|
||||||
|
|
||||||
|
visibleAlerts = computed(() => this.alerts().slice(0, this.visibleCount()));
|
||||||
|
hiddenCount = computed(() => Math.max(this.alerts().length - this.visibleCount(), 0));
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
toObservable(this.filters)
|
||||||
|
.pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.visibleCount.set(PAGE_SIZE);
|
||||||
|
}),
|
||||||
|
// Piège : catchError sur l'observable interne ; sur le flux externe il terminerait le
|
||||||
|
// timer et le rafraîchissement ne repartirait jamais.
|
||||||
|
switchMap((filters) =>
|
||||||
|
timer(0, REFRESH_INTERVAL_MS).pipe(
|
||||||
|
switchMap(() =>
|
||||||
|
this.alertsService
|
||||||
|
.getAlerts(filters)
|
||||||
|
.pipe(catchError(() => this.reportUnavailable())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((alerts) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(null);
|
||||||
|
this.alerts.set(alerts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSiteChange(event: Event): void {
|
||||||
|
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeverityChange(event: Event): void {
|
||||||
|
const value = (event.target as HTMLSelectElement).value;
|
||||||
|
this.severity.set(value ? (value as AlertSeverity) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
showMore(): void {
|
||||||
|
this.visibleCount.update((count) => count + PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
toneFor(severity: AlertSeverity): BadgeTone {
|
||||||
|
return TON_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
severityLabel(severity: AlertSeverity): string {
|
||||||
|
return LIBELLE_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
typeLabel(type: AlertType): string {
|
||||||
|
return LIBELLE_PAR_TYPE[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
siteName(siteId: string): string {
|
||||||
|
return this.siteNameById().get(siteId) ?? siteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
unitFor(metric: AlertMetric | null): string {
|
||||||
|
return metric ? UNITE_PAR_METRIQUE[metric] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportUnavailable(): Observable<never> {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="reco-list__banner">{{ message }}</ev-alert>
|
||||||
|
} @else if (loading() && !hasData()) {
|
||||||
|
<p class="reco-list__state" aria-live="polite">Chargement des recommandations…</p>
|
||||||
|
} @else if (visibleGroups().length === 0) {
|
||||||
|
<ev-alert severity="success" class="reco-list__banner">{{ emptyMessage() }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="reco-list" [attr.aria-busy]="loading()">
|
||||||
|
@for (group of visibleGroups(); track group.alert.alert_id) {
|
||||||
|
<ev-card
|
||||||
|
class="reco-group"
|
||||||
|
[class.reco-group--focus]="group.alert.alert_id === alertId()"
|
||||||
|
[id]="'alerte-' + group.alert.alert_id"
|
||||||
|
>
|
||||||
|
<header class="reco-group__alert">
|
||||||
|
<div class="reco-group__meta">
|
||||||
|
<ev-badge [tone]="toneFor(group.alert.severity)">{{
|
||||||
|
severityLabel(group.alert.severity)
|
||||||
|
}}</ev-badge>
|
||||||
|
<span class="reco-group__type">{{ typeLabel(group.alert.type) }}</span>
|
||||||
|
@if (!siteId()) {
|
||||||
|
<a [routerLink]="['/sites', group.alert.site_id]" class="ev-link">{{
|
||||||
|
group.siteName
|
||||||
|
}}</a>
|
||||||
|
}
|
||||||
|
<time [attr.datetime]="group.alert.timestamp">{{
|
||||||
|
group.alert.timestamp | date: 'dd/MM/yyyy HH:mm'
|
||||||
|
}}</time>
|
||||||
|
</div>
|
||||||
|
<p class="reco-group__message">{{ group.alert.message }}</p>
|
||||||
|
</header>
|
||||||
|
<ol class="reco-group__items">
|
||||||
|
@for (reco of group.recommendations; track reco.recommendation_id) {
|
||||||
|
<li class="reco">
|
||||||
|
<div class="reco__head">
|
||||||
|
<strong class="reco__action">{{ reco.action }}</strong>
|
||||||
|
<ev-badge [tone]="ruleTone(reco.rule_reference)">{{
|
||||||
|
ruleLabel(reco.rule_reference)
|
||||||
|
}}</ev-badge>
|
||||||
|
</div>
|
||||||
|
<p class="reco__explanation">{{ reco.explanation }}</p>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</ev-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-list__banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-list__state {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group {
|
||||||
|
padding: var(--space-4);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group--focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__alert {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__items {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco__action {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco__explanation {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { NEVER, of, throwError } from 'rxjs';
|
||||||
|
import { RecommendationList, joinByAlert } from './recommendation-list';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||||
|
import { Alert } from '../../models/alert.model';
|
||||||
|
import { Recommendation } from '../../models/recommendation.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
|
||||||
|
const SITES: Site[] = [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
site_id: 'SITE002',
|
||||||
|
site_name: 'Bureau Lille',
|
||||||
|
site_type: 'bureau',
|
||||||
|
location: 'Lille',
|
||||||
|
capacity_kw: 80,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function alerte(surcharges: Partial<Alert>): Alert {
|
||||||
|
return {
|
||||||
|
alert_id: 1,
|
||||||
|
site_id: 'SITE001',
|
||||||
|
timestamp: '2026-09-15T09:00:00Z',
|
||||||
|
type: 'threshold',
|
||||||
|
severity: 'high',
|
||||||
|
message: 'Puissance appelée au-dessus de la capacité du site',
|
||||||
|
value: 812.5,
|
||||||
|
threshold: 720,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reco(surcharges: Partial<Recommendation>): Recommendation {
|
||||||
|
return {
|
||||||
|
recommendation_id: 1,
|
||||||
|
alert_id: 1,
|
||||||
|
action: 'Ramener la puissance appelée sous le seuil contractuel',
|
||||||
|
explanation: 'Seuil de consommation dépassé sur le site SITE001.',
|
||||||
|
rule_reference: 'threshold-reduction-v1',
|
||||||
|
created_at: '2026-09-15T09:05:00Z',
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALERTES: Alert[] = [
|
||||||
|
alerte({ alert_id: 1, site_id: 'SITE001', timestamp: '2026-09-15T09:00:00Z' }),
|
||||||
|
alerte({
|
||||||
|
alert_id: 2,
|
||||||
|
site_id: 'SITE002',
|
||||||
|
timestamp: '2026-09-15T11:00:00Z',
|
||||||
|
severity: 'critical',
|
||||||
|
type: 'spike',
|
||||||
|
message: 'Variation brutale entre deux lectures consécutives',
|
||||||
|
}),
|
||||||
|
alerte({ alert_id: 3, site_id: 'SITE001', timestamp: '2026-09-15T10:00:00Z', severity: 'low' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const RECOMMANDATIONS: Recommendation[] = [
|
||||||
|
reco({
|
||||||
|
recommendation_id: 3,
|
||||||
|
alert_id: 2,
|
||||||
|
action: "Escalader à l'astreinte sous une heure",
|
||||||
|
rule_reference: 'escalade-astreinte-v1',
|
||||||
|
}),
|
||||||
|
reco({ recommendation_id: 1, alert_id: 1 }),
|
||||||
|
reco({
|
||||||
|
recommendation_id: 2,
|
||||||
|
alert_id: 2,
|
||||||
|
action: 'Délester les équipements non prioritaires sur le créneau du pic',
|
||||||
|
rule_reference: 'spike-delestage-v1',
|
||||||
|
}),
|
||||||
|
reco({ recommendation_id: 4, alert_id: 99, rule_reference: 'orpheline-v1' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
|
||||||
|
recosMock: { getRecommendations: ReturnType<typeof vi.fn> },
|
||||||
|
inputs: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [RecommendationList],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
|
{ provide: RecommendationsService, useValue: recosMock },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(RecommendationList);
|
||||||
|
for (const [nom, valeur] of Object.entries(inputs)) {
|
||||||
|
fixture.componentRef.setInput(nom, valeur);
|
||||||
|
}
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendre(fixture: ComponentFixture<RecommendationList>) {
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function texte(fixture: ComponentFixture<RecommendationList>): string {
|
||||||
|
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const recosOk = () => ({ getRecommendations: vi.fn().mockReturnValue(of(RECOMMANDATIONS)) });
|
||||||
|
|
||||||
|
describe('joinByAlert', () => {
|
||||||
|
it('groupe par alerte, du plus récent au plus ancien, recommandations par identifiant', () => {
|
||||||
|
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map([['SITE001', 'Usine Nantes']]));
|
||||||
|
|
||||||
|
expect(groupes.map((g) => g.alert.alert_id)).toEqual([2, 1]);
|
||||||
|
expect(groupes[0].recommendations.map((r) => r.recommendation_id)).toEqual([2, 3]);
|
||||||
|
expect(groupes[1].siteName).toBe('Usine Nantes');
|
||||||
|
expect(groupes[0].siteName).toBe('SITE002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore les alertes sans recommandation et les recommandations orphelines', () => {
|
||||||
|
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map());
|
||||||
|
|
||||||
|
expect(groupes.some((g) => g.alert.alert_id === 3)).toBe(false);
|
||||||
|
expect(groupes.flatMap((g) => g.recommendations).some((r) => r.alert_id === 99)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecommendationList', () => {
|
||||||
|
it('charge alertes et recommandations puis affiche les groupes avec leur contexte', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
|
||||||
|
const fixture = setup({ getAlerts }, recosOk(), { sites: SITES });
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({});
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(2);
|
||||||
|
const contenu = texte(fixture);
|
||||||
|
expect(contenu).toContain('Usine Nantes');
|
||||||
|
expect(contenu).toContain('Bureau Lille');
|
||||||
|
expect(contenu).toContain('Critique');
|
||||||
|
expect(contenu).toContain('Pic de consommation');
|
||||||
|
expect(contenu).toContain('Escalade astreinte');
|
||||||
|
expect(contenu).toContain('Délester les équipements');
|
||||||
|
expect(contenu).toContain('15/09/2026');
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE002"]')).not.toBeNull();
|
||||||
|
expect(fixture.componentInstance.total()).toBe(3);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filtre les alertes du site côté API et masque le lien vers le site', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of(ALERTES.filter((a) => a.site_id === 'SITE001')));
|
||||||
|
const fixture = setup({ getAlerts }, recosOk(), { siteId: 'SITE001', sites: SITES });
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(1);
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href^="/sites/"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne garde que le groupe de l'alerte ciblée et le met en évidence", () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
|
||||||
|
alertId: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
const groupes = fixture.nativeElement.querySelectorAll('.reco-group');
|
||||||
|
expect(groupes.length).toBe(1);
|
||||||
|
expect(groupes[0].classList.contains('reco-group--focus')).toBe(true);
|
||||||
|
expect(groupes[0].id).toBe('alerte-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence de recommandation pour une alerte inconnue", () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
|
||||||
|
alertId: 123,
|
||||||
|
});
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('Aucune recommandation pour cette alerte.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence de recommandation pour le site consulté", () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(of([])) },
|
||||||
|
{ getRecommendations: vi.fn().mockReturnValue(of([])) },
|
||||||
|
{ siteId: 'SITE001' },
|
||||||
|
);
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('Aucune recommandation pour ce site.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'indisponibilité et n'affiche aucun groupe si un des deux appels échoue", () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) },
|
||||||
|
{ getRecommendations: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
|
);
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||||
|
expect(fixture.componentInstance.groups()).toEqual([]);
|
||||||
|
expect(texte(fixture)).toContain('Recommandations indisponibles');
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annonce le chargement tant que la réponse ne vient pas', () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(NEVER) },
|
||||||
|
{ getRecommendations: vi.fn().mockReturnValue(NEVER) },
|
||||||
|
);
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.loading()).toBe(true);
|
||||||
|
expect(texte(fixture)).toContain('Chargement des recommandations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recharge les deux flux à la demande', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
|
||||||
|
const recos = recosOk();
|
||||||
|
const fixture = setup({ getAlerts }, recos);
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
fixture.componentInstance.reload();
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(recos.getRecommendations).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { catchError, EMPTY, forkJoin, Observable, switchMap, tap } from 'rxjs';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||||
|
import { Alert, AlertSeverity, AlertType } from '../../models/alert.model';
|
||||||
|
import { Recommendation } from '../../models/recommendation.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
import {
|
||||||
|
LIBELLE_PAR_SEVERITE,
|
||||||
|
LIBELLE_PAR_TYPE,
|
||||||
|
TON_PAR_SEVERITE,
|
||||||
|
} from '../../models/alert-presentation';
|
||||||
|
import { libelleRegle, tonRegle } from '../../models/recommendation-presentation';
|
||||||
|
import { Card } from '../ui/card/card';
|
||||||
|
import { Badge, BadgeTone } from '../ui/badge/badge';
|
||||||
|
import { Alert as EvAlert } from '../ui/alert/alert';
|
||||||
|
|
||||||
|
const UNAVAILABLE_MESSAGE = 'Recommandations indisponibles, réessayez plus tard.';
|
||||||
|
|
||||||
|
export interface RecommendedAlertView {
|
||||||
|
alert: Alert;
|
||||||
|
siteName: string;
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chargement {
|
||||||
|
alerts: Alert[];
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pourquoi : une recommandation ne porte que alert_id, jamais site_id, et /recommendations n'a
|
||||||
|
// aucun filtre ; la jointure se fait ici, en O(alertes), acceptable à la taille du jeu de données.
|
||||||
|
export function joinByAlert(
|
||||||
|
alerts: Alert[],
|
||||||
|
recommendations: Recommendation[],
|
||||||
|
siteNames: Map<string, string>,
|
||||||
|
): RecommendedAlertView[] {
|
||||||
|
const parAlerte = new Map<number, Recommendation[]>();
|
||||||
|
for (const recommandation of recommendations) {
|
||||||
|
const liste = parAlerte.get(recommandation.alert_id) ?? [];
|
||||||
|
liste.push(recommandation);
|
||||||
|
parAlerte.set(recommandation.alert_id, liste);
|
||||||
|
}
|
||||||
|
return alerts
|
||||||
|
.filter((alert) => parAlerte.has(alert.alert_id))
|
||||||
|
.map((alert) => ({
|
||||||
|
alert,
|
||||||
|
siteName: siteNames.get(alert.site_id) ?? alert.site_id,
|
||||||
|
recommendations: [...(parAlerte.get(alert.alert_id) ?? [])].sort(
|
||||||
|
(a, b) => a.recommendation_id - b.recommendation_id,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Date.parse(b.alert.timestamp) - Date.parse(a.alert.timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-recommendation-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DatePipe, RouterLink, Card, Badge, EvAlert],
|
||||||
|
templateUrl: './recommendation-list.html',
|
||||||
|
styleUrl: './recommendation-list.scss',
|
||||||
|
})
|
||||||
|
export class RecommendationList {
|
||||||
|
private alertsService = inject(AlertsService);
|
||||||
|
private recommendationsService = inject(RecommendationsService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
siteId = input<string | null>(null);
|
||||||
|
alertId = input<number | null>(null);
|
||||||
|
sites = input<Site[]>([]);
|
||||||
|
|
||||||
|
private data = signal<Chargement | null>(null);
|
||||||
|
private reloadTick = signal(0);
|
||||||
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
private trigger = computed(() => ({ siteId: this.siteId(), tick: this.reloadTick() }));
|
||||||
|
|
||||||
|
private siteNameById = computed(
|
||||||
|
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
|
||||||
|
);
|
||||||
|
|
||||||
|
hasData = computed(() => this.data() !== null);
|
||||||
|
|
||||||
|
groups = computed<RecommendedAlertView[]>(() => {
|
||||||
|
const data = this.data();
|
||||||
|
return data ? joinByAlert(data.alerts, data.recommendations, this.siteNameById()) : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
visibleGroups = computed(() => {
|
||||||
|
const alertId = this.alertId();
|
||||||
|
const groups = this.groups();
|
||||||
|
return alertId === null ? groups : groups.filter((group) => group.alert.alert_id === alertId);
|
||||||
|
});
|
||||||
|
|
||||||
|
total = computed(() =>
|
||||||
|
this.visibleGroups().reduce((somme, group) => somme + group.recommendations.length, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
emptyMessage = computed(() => {
|
||||||
|
if (this.alertId() !== null) {
|
||||||
|
return 'Aucune recommandation pour cette alerte.';
|
||||||
|
}
|
||||||
|
return this.siteId()
|
||||||
|
? 'Aucune recommandation pour ce site.'
|
||||||
|
: 'Aucune recommandation pour le moment.';
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
toObservable(this.trigger)
|
||||||
|
.pipe(
|
||||||
|
tap(() => this.loading.set(true)),
|
||||||
|
switchMap(({ siteId }) =>
|
||||||
|
forkJoin({
|
||||||
|
alerts: this.alertsService.getAlerts(siteId ? { site_id: siteId } : {}),
|
||||||
|
recommendations: this.recommendationsService.getRecommendations(),
|
||||||
|
}).pipe(catchError(() => this.reportUnavailable())),
|
||||||
|
),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((data) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(null);
|
||||||
|
this.data.set(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
this.reloadTick.update((tick) => tick + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
toneFor(severity: AlertSeverity): BadgeTone {
|
||||||
|
return TON_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
severityLabel(severity: AlertSeverity): string {
|
||||||
|
return LIBELLE_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
typeLabel(type: AlertType): string {
|
||||||
|
return LIBELLE_PAR_TYPE[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleLabel(reference: string): string {
|
||||||
|
return libelleRegle(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleTone(reference: string): BadgeTone {
|
||||||
|
return tonRegle(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Piège : vider les données avec l'erreur ; une demi-jointure (alertes sans recommandations,
|
||||||
|
// ou l'inverse) afficherait des groupes faux plutôt que rien.
|
||||||
|
private reportUnavailable(): Observable<never> {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
this.data.set(null);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
focusable="false"
|
||||||
|
[attr.role]="label() ? 'img' : null"
|
||||||
|
[attr.aria-label]="label()"
|
||||||
|
[attr.aria-hidden]="label() ? null : 'true'"
|
||||||
|
>
|
||||||
|
@switch (name()) {
|
||||||
|
@case ('spike') {
|
||||||
|
<polyline points="3 17 8 12 11 15 21 5" />
|
||||||
|
<polyline points="15 5 21 5 21 11" />
|
||||||
|
}
|
||||||
|
@case ('threshold') {
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" stroke-dasharray="3 3" />
|
||||||
|
<polyline points="4 19 9 13 13 15 20 6" />
|
||||||
|
}
|
||||||
|
@case ('anomaly') {
|
||||||
|
<polyline points="3 12 7 12 9.5 6 12.5 18 15 12 21 12" />
|
||||||
|
}
|
||||||
|
@case ('outage') {
|
||||||
|
<path d="M7.5 6.5a7 7 0 1 0 9 0" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="11" />
|
||||||
|
}
|
||||||
|
@case ('sensor') {
|
||||||
|
<circle cx="12" cy="13" r="2" />
|
||||||
|
<path d="M8.5 9.5a5 5 0 0 0 0 7" />
|
||||||
|
<path d="M15.5 9.5a5 5 0 0 1 0 7" />
|
||||||
|
<path d="M5.5 6.5a9 9 0 0 0 0 13" />
|
||||||
|
<path d="M18.5 6.5a9 9 0 0 1 0 13" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { Icon, IconName } from './icon';
|
||||||
|
|
||||||
|
const NOMS: IconName[] = ['spike', 'threshold', 'anomaly', 'outage', 'sensor'];
|
||||||
|
|
||||||
|
function rendre(name: IconName, label: string | null = null) {
|
||||||
|
const fixture = TestBed.createComponent(Icon);
|
||||||
|
fixture.componentRef.setInput('name', name);
|
||||||
|
fixture.componentRef.setInput('label', label);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Icon', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({ imports: [Icon] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dessine un tracé distinct pour chacun des cinq types', () => {
|
||||||
|
const traces = NOMS.map((name) => rendre(name).querySelector('svg')?.innerHTML.trim());
|
||||||
|
|
||||||
|
for (const trace of traces) {
|
||||||
|
expect(trace).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(new Set(traces).size).toBe(NOMS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reste décoratif sans libellé', () => {
|
||||||
|
const svg = rendre('spike').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(svg?.hasAttribute('role')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expose un rôle image et un libellé accessible quand on lui en donne un', () => {
|
||||||
|
const svg = rendre('outage', 'Coupure').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('role')).toBe('img');
|
||||||
|
expect(svg?.getAttribute('aria-label')).toBe('Coupure');
|
||||||
|
expect(svg?.hasAttribute('aria-hidden')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hérite de la couleur du parent via currentColor', () => {
|
||||||
|
const svg = rendre('sensor').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('stroke')).toBe('currentColor');
|
||||||
|
expect(svg?.getAttribute('fill')).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
export type IconName = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ev-icon',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './icon.html',
|
||||||
|
styleUrl: './icon.scss',
|
||||||
|
})
|
||||||
|
export class Icon {
|
||||||
|
name = input.required<IconName>();
|
||||||
|
label = input<string | null>(null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
LIBELLE_PAR_SEVERITE,
|
||||||
|
LIBELLE_PAR_TYPE,
|
||||||
|
SEVERITES,
|
||||||
|
TON_PAR_SEVERITE,
|
||||||
|
TYPES_ALERTE,
|
||||||
|
UNITE_PAR_METRIQUE,
|
||||||
|
} from './alert-presentation';
|
||||||
|
|
||||||
|
describe('alert-presentation', () => {
|
||||||
|
it('distingue le ton des sévérités high et critical', () => {
|
||||||
|
expect(TON_PAR_SEVERITE.high).toBe('danger');
|
||||||
|
expect(TON_PAR_SEVERITE.critical).toBe('critical');
|
||||||
|
expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'affiche pas une alerte faible avec le ton de succès", () => {
|
||||||
|
expect(TON_PAR_SEVERITE.low).toBe('neutral');
|
||||||
|
expect(TON_PAR_SEVERITE.medium).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('donne un libellé français à chaque sévérité et à chaque type', () => {
|
||||||
|
for (const severite of SEVERITES) {
|
||||||
|
expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy();
|
||||||
|
}
|
||||||
|
for (const type of TYPES_ALERTE) {
|
||||||
|
expect(LIBELLE_PAR_TYPE[type]).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(SEVERITES.length).toBe(4);
|
||||||
|
expect(TYPES_ALERTE.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associe une unité à chaque métrique du contrat', () => {
|
||||||
|
expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW');
|
||||||
|
expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user