diff --git a/.env.example b/.env.example index e909bd1..3125b73 100644 --- a/.env.example +++ b/.env.example @@ -29,15 +29,18 @@ APP_MOCK_API_USERNAME=change_me APP_MOCK_API_PASSWORD=change_me APP_MOCK_API_TIMEOUT_SECONDS=10 -# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le +# 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. AIRFLOW_PORT=8080 # Chiffre les connexions/variables stockées par Airflow. Générer la vôtre : # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" AIRFLOW_FERNET_KEY=change_me -# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la +# 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))" -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 # Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les # comptes `app_user` d'EnerVision. @@ -53,3 +56,21 @@ AIRFLOW_APP_SECRET_KEY=change_me # 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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a925ee4..a02f919 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,15 @@ updates: frontend-dependencies: 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) - package-ecosystem: "uv" diff --git a/.github/workflows/airflow.yml b/.github/workflows/airflow.yml index d5a722a..0d652d9 100644 --- a/.github/workflows/airflow.yml +++ b/.github/workflows/airflow.yml @@ -1,8 +1,9 @@ name: Airflow # Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14 -# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de -# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile). +# (contrairement à backend.yml et ml.yml) : celui de l'image apache/airflow retenue, et les tests +# 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/ et de apps/backend/. Une # modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans @@ -47,10 +48,10 @@ jobs: steps: - name: Récupère le dépôt - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Installe uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: etl/airflow/uv.lock @@ -78,7 +79,7 @@ jobs: steps: - 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/ et apps/backend/) run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci . @@ -91,11 +92,13 @@ jobs: bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help" # `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et - # l'import du module prouve que l'environnement /opt/backend est complet. Les deux - # commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui. - - name: Vérifie que les deux commandes du DAG alertes s'importent sans réseau + # 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.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" \ No newline at end of file diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 34837cd..84c0664 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -30,10 +30,10 @@ jobs: steps: - name: Récupère le dépôt - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Installe uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: apps/backend/uv.lock @@ -90,10 +90,10 @@ jobs: steps: - name: Récupère le dépôt - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Installe uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: apps/backend/uv.lock @@ -126,10 +126,10 @@ jobs: steps: - name: Récupère le dépôt - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Installe uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: apps/backend/uv.lock @@ -150,12 +150,12 @@ jobs: steps: - name: Récupère le dépôt - uses: actions/checkout@v4 + 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@v5 + 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. diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml new file mode 100644 index 0000000..095781e --- /dev/null +++ b/.github/workflows/dast.yml @@ -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 `), 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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..411e164 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index daaa6e3..1ba4010 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -17,10 +17,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 26 cache: npm cache-dependency-path: apps/frontend/package-lock.json @@ -33,10 +33,10 @@ jobs: name: Audit des dépendances runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 26 # Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison. - run: npm audit --audit-level=high --package-lock-only working-directory: apps/frontend @@ -45,10 +45,10 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 26 cache: npm cache-dependency-path: apps/frontend/package-lock.json - name : Installation des dépendances (Front) @@ -58,7 +58,7 @@ jobs: run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend - name: Upload coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: frontend-coverage path: apps/frontend/coverage/frontend/lcov.info diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml new file mode 100644 index 0000000..a49088e --- /dev/null +++ b/.github/workflows/infra.yml @@ -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 diff --git a/.github/workflows/ml.yml b/.github/workflows/ml.yml index 00189e2..4145c4e 100644 --- a/.github/workflows/ml.yml +++ b/.github/workflows/ml.yml @@ -8,10 +8,28 @@ on: paths: - "ml/**" - ".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: paths: - "ml/**" - ".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: contents: read @@ -30,10 +48,10 @@ jobs: steps: - name: Récupère le dépôt - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Installe uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: ml/uv.lock @@ -53,11 +71,91 @@ jobs: - name: Typage run: uv run mypy enervision_ml tests - # Aucun test ne touche PostgreSQL ni MLflow distant : tout tourne sur donnees - # synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py). + # Les tests exigeant une base portent le marqueur `integration`, ecarte par defaut et + # joue par le job `integration` ci-dessous. - name: Tests 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 @@ -67,12 +165,12 @@ jobs: steps: - name: Récupère le dépôt - uses: actions/checkout@v4 + 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@v5 + 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 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index b5a07eb..4544c4e 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -5,11 +5,15 @@ on: paths: - "apps/frontend/**" - "apps/backend/**" + - "ml/**" + - "etl/airflow/**" - ".github/workflows/sonarqube.yml" pull_request: paths: - "apps/frontend/**" - "apps/backend/**" + - "ml/**" + - "etl/airflow/**" - ".github/workflows/sonarqube.yml" @@ -20,10 +24,10 @@ jobs: build-front: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 26 cache: npm cache-dependency-path: apps/frontend/package-lock.json @@ -36,10 +40,10 @@ jobs: needs: build-front runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 26 cache: npm cache-dependency-path: apps/frontend/package-lock.json @@ -52,7 +56,7 @@ jobs: working-directory: apps/frontend - name: Upload coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: frontend-coverage path: apps/frontend/coverage/frontend/lcov.info @@ -60,9 +64,9 @@ jobs: build-back: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Installe uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: apps/backend/uv.lock @@ -91,9 +95,9 @@ jobs: needs: build-back runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Installe uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: apps/backend/uv.lock @@ -103,29 +107,65 @@ jobs: working-directory: apps/backend - name: Upload coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: backend-coverage path: apps/backend/coverage.xml - sonarqube: - needs: [build-front, build-back, test-front, test-back] - name: SonarQube + test-ml: runs-on: ubuntu-latest 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: fetch-depth: 0 - name: Téléchargement du rapport de couverture (Front) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: frontend-coverage path: apps/frontend/coverage/frontend - name: Téléchargement du rapport de couverture (Back) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: backend-coverage path: apps/backend + - name: Téléchargement du rapport de couverture (ML) + uses: actions/download-artifact@v8 + with: + name: ml-coverage + path: ml - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v8 env: diff --git a/.gitignore b/.gitignore index cc7162c..c15d9ae 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,6 @@ kubeconfig # Airflow etl/airflow/logs/ airflow.db -airflow-webserver.pid standalone_admin_password.txt # Environnement et secrets @@ -64,7 +63,8 @@ ml/models/* !ml/models/.gitkeep ml/mlruns/ 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) etl/airflow/tests/.airflow_home/ diff --git a/Makefile b/Makefile index 9d4beb4..9a270f8 100644 --- a/Makefile +++ b/Makefile @@ -13,12 +13,39 @@ 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 .PHONY: help install install-backend install-frontend install-ml install-airflow \ dev dev-backend dev-frontend \ - lint format typecheck test test-cov test-integration check \ - openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ - ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \ + lint format typecheck test test-cov test-integration ml-test-integration \ + test-chaine check \ + openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \ + 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 @@ -39,12 +66,19 @@ install-ml: ## Installe les dépendances du pipeline ML install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow cd $(AIRFLOW) && uv sync --all-groups -dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud +dev: 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; \ $(MAKE) --no-print-directory dev-backend & \ $(MAKE) --no-print-directory dev-frontend & \ 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 @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 @@ -88,11 +122,29 @@ 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 +# 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 cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),) -ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel - cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) +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),) $(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),) @@ -108,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-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant. - docker compose up -d airflow-init airflow-webserver airflow-scheduler +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-apiserver airflow-scheduler airflow-dag-processor @echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}" -airflow-down: ## Arrête le webserver et le scheduler Airflow - docker compose stop airflow-webserver airflow-scheduler +airflow-down: ## Arrête l'api-server, le scheduler et le dag-processor Airflow + docker compose stop airflow-apiserver airflow-scheduler airflow-dag-processor airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor) docker compose logs -f airflow-scheduler @@ -124,12 +176,15 @@ docker-build: ## Construit l'image du backend tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser ./scripts/tls-selfsigned.sh $(if $(FORCE),--force,) -stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin +# 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 @@ -165,8 +220,39 @@ db-logs: ## Suit les journaux de la base db-psql: ## Ouvre une session psql sur la base applicative 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 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 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 diff --git a/README.md b/README.md index cc139dc..f068f67 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ series temporelles energetiques, deployee sur une machine on-premise. |-------|----------------------------------------------------------| | J1 | Valider la préparation de l'environnement et du repo | | J2 | Valider le périmètre retenu et les choix technologiques | -| J3 | Valider l'architecture et la gestion de la sécurité | -| J4 | Valider la robustesse et assurer les livrables | +| J3 | Ingestion & backend | +| 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). @@ -18,15 +20,15 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Domaine | Technologie | Emplacement | Etat | |------------|-------------------------------------|---------------------|---------------| -| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | -| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord | -| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | -| ETL | Apache Airflow | `etl/airflow` | Trois DAGs | +| Backend | FastAPI, Python 3.14 | `apps/backend` | En place | +| Frontend | Angular 22, Node 26 | `apps/frontend` | En place | +| Base | PostgreSQL 17 + TimescaleDB | `db` | En place | +| ETL | Apache Airflow | `etl/airflow` | Cinq DAGs | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | Reverse proxy | Nginx, TLS | `infra/proxy` | En place | -| CI/CD | GitHub Actions | `.github/workflows` | Backend en place | +| CI/CD | GitHub Actions | `.github/workflows` | En place | | 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 sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints @@ -48,7 +50,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans │ ├── migrations/ Migrations SQL versionnees │ └── seeds/ Jeux de donnees de reference ├── etl/airflow/ -│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes) +│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, imports, dérive) │ ├── plugins/ Operateurs et hooks maison │ ├── include/ Requetes SQL et ressources des DAGs │ └── tests/ Tests d'integrite des DAGs @@ -75,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 apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur -make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433 -make install # dependances du backend et du frontend -make migrate # applique les migrations Alembic -make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200 +make install # dependances du backend, du frontend, du ML et des DAGs +make dev # toute la stack, voir ci-dessous 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 | (documentation sur `/docs`) | +| Frontend | | +| Airflow | (`AIRFLOW_ADMIN_USERNAME` / `AIRFLOW_ADMIN_PASSWORD` du `.env`) | +| Mailpit | | + +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. 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 5432, souvent deja pris par une autre base. -La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en -conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par -`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend` -et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend` -du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc -toute modification y demande un `docker compose up -d --build backend`. +Le backend et le frontend tournent sur le poste, lances ensemble par `make dev` (logs +entrelaces dans le meme terminal, Ctrl+C arrete les deux) ; la base, Mailpit et Airflow tournent +en conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette, +et n'embarque pas le source, donc toute modification y demande un +`docker compose up -d --build backend`. + +### 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 : @@ -116,6 +146,12 @@ nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvel [`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 - Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 9374a0f..907d0a0 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -16,6 +16,10 @@ WORKDIR /app COPY uv.lock pyproject.toml /app/ RUN uv sync --locked --no-install-project --no-dev +# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le +# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire +# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que +# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541). COPY . /app RUN uv sync --locked --no-dev diff --git a/apps/backend/alembic/versions/d3f1a2b7c904_rapports_de_derive.py b/apps/backend/alembic/versions/d3f1a2b7c904_rapports_de_derive.py new file mode 100644 index 0000000..b64c6ba --- /dev/null +++ b/apps/backend/alembic/versions/d3f1a2b7c904_rapports_de_derive.py @@ -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") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 6662f81..d775b7c 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -24,6 +24,7 @@ from app.core.security import decode_access_token as decode_token from app.db.session import get_session from app.repositories.alert import AlertRepository from app.repositories.audit_log import AuditLogRepository +from app.repositories.drift import DriftRepository from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.password_reset_attempt import PasswordResetAttemptRepository 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.services.alert import AlertService from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy +from app.services.drift import DriftService from app.services.prediction import PredictionService from app.services.reading import ReadingService from app.services.recommendation import RecommendationService @@ -48,7 +50,9 @@ SettingsDep = Annotated[Settings, Depends(get_settings)] 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)] @@ -232,6 +236,13 @@ def get_prediction_service(session: SessionDep) -> PredictionService: 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( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 8ca8c08..3fed7e9 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -90,11 +90,21 @@ TAGS: Final[list[dict[str, Any]]] = [ "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( 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=( "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 " @@ -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 # insuffisants, seulement pour le mot de passe provisoire. REPONSES_LECTEUR: Final[Reponses] = { diff --git a/apps/backend/app/api/v1/endpoints/monitoring.py b/apps/backend/app/api/v1/endpoints/monitoring.py new file mode 100644 index 0000000..031b36a --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/monitoring.py @@ -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] diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 6079acf..c3c39d4 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,10 +1,16 @@ 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 ( alerts, auth, health, + monitoring, predictions, readings, recommendations, @@ -38,3 +44,6 @@ api_router.include_router( api_router.include_router( predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR ) +api_router.include_router( + monitoring.router, prefix="/monitoring", tags=["monitoring"], responses=REPONSES_OPERATEUR +) diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py index 0d5d6be..65fcea5 100644 --- a/apps/backend/app/etl/mock_api_import.py +++ b/apps/backend/app/etl/mock_api_import.py @@ -45,15 +45,19 @@ CAPACITY_BOUNDS = (0.0, 100_000.0) def create_mock_api_client() -> httpx.AsyncClient: settings = get_settings() - if settings.mock_api_username is None or settings.mock_api_password is None: + 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.") return httpx.AsyncClient( base_url=settings.mock_api_base_url.rstrip("/"), - auth=( - settings.mock_api_username, - settings.mock_api_password.get_secret_value(), - ), + auth=(username, password), timeout=settings.mock_api_timeout_seconds, ) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 167d7ce..8032c17 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -2,7 +2,15 @@ # --autogenerate`, qui générerait alors un drop de sa table. 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.password_reset_attempt import PasswordResetAttempt from app.models.password_reset_token import PasswordResetToken @@ -14,6 +22,7 @@ __all__ = [ "AppUser", "AuditLog", "Dataset", + "DriftReport", "LoginAttempt", "PasswordResetAttempt", "PasswordResetToken", diff --git a/apps/backend/app/models/energy.py b/apps/backend/app/models/energy.py index 285ad26..ce4fcac 100644 --- a/apps/backend/app/models/energy.py +++ b/apps/backend/app/models/energy.py @@ -208,3 +208,49 @@ class Recommendation(Base): explanation: 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()) + + +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, +) diff --git a/infra/terraform/environments/prod/.gitkeep b/apps/backend/app/monitoring/__init__.py similarity index 100% rename from infra/terraform/environments/prod/.gitkeep rename to apps/backend/app/monitoring/__init__.py diff --git a/apps/backend/app/monitoring/drift.py b/apps/backend/app/monitoring/drift.py new file mode 100644 index 0000000..76fc0d6 --- /dev/null +++ b/apps/backend/app/monitoring/drift.py @@ -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()) diff --git a/apps/backend/app/repositories/alert.py b/apps/backend/app/repositories/alert.py index f495a3b..b7d6eb2 100644 --- a/apps/backend/app/repositories/alert.py +++ b/apps/backend/app/repositories/alert.py @@ -6,6 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession 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: def __init__(self, session: AsyncSession) -> None: @@ -44,12 +48,17 @@ class AlertRepository: } for alerte in alerts ] - requete = ( - insert(Alert) - .values(valeurs) - .on_conflict_do_nothing(constraint="uq_alert_source_reference") - .returning(Alert) - ) - resultat = await self._session.execute(requete) + creees: list[Alert] = [] + # Piège : asyncpg plafonne une requête à 32 767 paramètres. Une détection sur une fenêtre + # chargée dépasse ce seuil, et l'`INSERT` d'un seul tenant échouerait. + for debut in range(0, len(valeurs), TAILLE_DE_LOT): + requete = ( + insert(Alert) + .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() - return resultat.scalars().all() + return creees diff --git a/apps/backend/app/repositories/drift.py b/apps/backend/app/repositories/drift.py new file mode 100644 index 0000000..712228c --- /dev/null +++ b/apps/backend/app/repositories/drift.py @@ -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() diff --git a/apps/backend/app/schemas/drift.py b/apps/backend/app/schemas/drift.py new file mode 100644 index 0000000..6c6780c --- /dev/null +++ b/apps/backend/app/schemas/drift.py @@ -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 diff --git a/apps/backend/app/services/drift.py b/apps/backend/app/services/drift.py new file mode 100644 index 0000000..71bc41a --- /dev/null +++ b/apps/backend/app/services/drift.py @@ -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 diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 67b3877..c73a8b1 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -213,7 +213,7 @@ }, "security": [ { - "Cookie de rafraîchissement": [] + "CookieRafraichissement": [] } ] } @@ -252,7 +252,7 @@ }, "security": [ { - "Cookie de rafraîchissement": [] + "CookieRafraichissement": [] } ] } @@ -301,7 +301,7 @@ }, "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ] } @@ -347,7 +347,7 @@ }, "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ] } @@ -423,7 +423,7 @@ }, "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ] } @@ -673,7 +673,7 @@ }, "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ] }, @@ -757,7 +757,7 @@ }, "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ] } @@ -771,7 +771,7 @@ "operationId": "update_user_api_v1_users__user_id__patch", "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ], "parameters": [ @@ -889,7 +889,7 @@ "operationId": "reset_password_api_v1_users__user_id__password_reset_post", "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ], "parameters": [ @@ -1023,7 +1023,7 @@ }, "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ] } @@ -1037,7 +1037,7 @@ "operationId": "get_site_api_v1_sites__site_id__get", "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ], "parameters": [ @@ -1124,7 +1124,7 @@ "operationId": "get_current_api_v1_sites__site_id__current_get", "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ], "parameters": [ @@ -1211,7 +1211,7 @@ "operationId": "list_alerts_api_v1_alerts_get", "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ], "parameters": [ @@ -1361,7 +1361,7 @@ }, "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ] } @@ -1375,7 +1375,7 @@ "operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get", "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ], "parameters": [ @@ -1462,7 +1462,7 @@ "operationId": "generate_recommendations_api_v1_recommendations_generate_post", "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ], "parameters": [ @@ -1588,7 +1588,7 @@ }, "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ] } @@ -1602,7 +1602,7 @@ "operationId": "list_readings_api_v1_readings_get", "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ], "parameters": [ @@ -1799,7 +1799,7 @@ }, "security": [ { - "Jeton d'accès": [] + "JetonAcces": [] } ] } @@ -1855,10 +1855,98 @@ }, "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": { @@ -1977,6 +2065,180 @@ ], "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": { "properties": { "detail": { @@ -3225,13 +3487,13 @@ } }, "securitySchemes": { - "Cookie de rafraîchissement": { + "CookieRafraichissement": { "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`.", "in": "cookie", "name": "ev_refresh" }, - "Jeton d'accès": { + "JetonAcces": { "type": "http", "scheme": "bearer" } @@ -3277,6 +3539,10 @@ { "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`." + }, + { + "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é." } ] } diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index cfe6481..e884616 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -87,8 +87,11 @@ disallow_untyped_defs = false testpaths = ["tests"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" -addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing" -markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"] +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`", + "chaine: requiert en plus l'environnement uv de ml/, hors `make test` et hors `-m integration`", +] [tool.coverage.run] source = ["app"] diff --git a/apps/backend/tests/api/acces.py b/apps/backend/tests/api/acces.py index 2b38374..2b1f640 100644 --- a/apps/backend/tests/api/acces.py +++ b/apps/backend/tests/api/acces.py @@ -56,6 +56,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = { ("GET", "/api/v1/readings"): Role.LECTEUR, ("GET", "/api/v1/predictions"): Role.LECTEUR, ("GET", "/api/v1/sensors/status"): Role.ADMIN, + ("GET", "/api/v1/monitoring/drift"): Role.OPERATEUR, ("GET", "/api/v1/users"): Role.ADMIN, ("POST", "/api/v1/users"): Role.ADMIN, ("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN, diff --git a/apps/backend/tests/api/conftest.py b/apps/backend/tests/api/conftest.py new file mode 100644 index 0000000..12ca9f9 --- /dev/null +++ b/apps/backend/tests/api/conftest.py @@ -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) diff --git a/apps/backend/tests/api/test_matrice_acces.py b/apps/backend/tests/api/test_matrice_acces.py index 5def3a7..8833596 100644 --- a/apps/backend/tests/api/test_matrice_acces.py +++ b/apps/backend/tests/api/test_matrice_acces.py @@ -227,24 +227,26 @@ async def test_a_real_token_reaches_exactly_the_routes_of_its_rank( assert ecarts == [] -# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture -# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur -# arrive sans que `ROLE_MINIMUM` soit mis à jour. +# Contrainte : les deux rangs ne se séparent que sur les routes que `ROLE_MINIMUM` réserve à +# `operateur`. Une route d'opérateur ajoutée sans être classée fait diverger les statuts sans +# 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 -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 ) -> None: lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR]) 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_operateur = await appelle(client, methode, chemin, headers=operateur) - if cote_lecteur.status_code != cote_operateur.status_code: - divergences.append((methode, chemin)) + diverge = cote_lecteur.status_code != cote_operateur.status_code + 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 diff --git a/apps/backend/tests/api/test_monitoring.py b/apps/backend/tests/api/test_monitoring.py new file mode 100644 index 0000000..68b2da1 --- /dev/null +++ b/apps/backend/tests/api/test_monitoring.py @@ -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() diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index 50e3c3a..3fba602 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -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: schemes = schema["components"]["securitySchemes"] - assert schemes["Cookie de rafraîchissement"]["in"] == "cookie" - assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh" + assert schemes["CookieRafraichissement"]["in"] == "cookie" + assert schemes["CookieRafraichissement"]["name"] == "ev_refresh" def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None: diff --git a/apps/backend/tests/api/test_parcours_recommandations.py b/apps/backend/tests/api/test_parcours_recommandations.py new file mode 100644 index 0000000..0e3e4e8 --- /dev/null +++ b/apps/backend/tests/api/test_parcours_recommandations.py @@ -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" diff --git a/apps/backend/tests/api/test_parcours_sites.py b/apps/backend/tests/api/test_parcours_sites.py new file mode 100644 index 0000000..7245fb4 --- /dev/null +++ b/apps/backend/tests/api/test_parcours_sites.py @@ -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" diff --git a/apps/backend/tests/db/test_data_schema.py b/apps/backend/tests/db/test_data_schema.py index c564042..e816d9c 100644 --- a/apps/backend/tests/db/test_data_schema.py +++ b/apps/backend/tests/db/test_data_schema.py @@ -1,5 +1,5 @@ from collections.abc import AsyncIterator -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest @@ -9,7 +9,15 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine 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 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) + savepoint = data_connection.begin_nested() + with pytest.raises(IntegrityError): - async with data_connection.begin_nested(): + async with savepoint: await data_connection.execute(statement) @@ -147,9 +157,12 @@ async def test_invalid_reading_is_rejected_when_constraints_fail( } values.update(changes) + statement = insert(Reading).values(**values) + savepoint = data_connection.begin_nested() + with pytest.raises(IntegrityError): - async with data_connection.begin_nested(): - await data_connection.execute(insert(Reading).values(**values)) + async with savepoint: + await data_connection.execute(statement) 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", ) + savepoint = data_connection.begin_nested() + with pytest.raises(IntegrityError): - async with data_connection.begin_nested(): + async with savepoint: await data_connection.execute(statement) @@ -212,21 +227,22 @@ async def test_alert_rejects_prediction_when_site_differs( ) ).scalar_one() + statement = insert(Alert).values( + source_alert_id=str(uuid4()), + site_id=other_site, + source="enervision", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + prediction_id=prediction_id, + raw_data={}, + ) + savepoint = data_connection.begin_nested() + with pytest.raises(IntegrityError): - async with data_connection.begin_nested(): - await data_connection.execute( - insert(Alert).values( - source_alert_id=str(uuid4()), - site_id=other_site, - source="enervision", - timestamp=MOMENT, - type="spike", - severity="high", - message="Test", - prediction_id=prediction_id, - raw_data={}, - ) - ) + async with savepoint: + await data_connection.execute(statement) 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) + savepoint = data_connection.begin_nested() + 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) diff --git a/apps/backend/tests/etl/test_historical_import.py b/apps/backend/tests/etl/test_historical_import.py index 31f6e2d..2f3ea92 100644 --- a/apps/backend/tests/etl/test_historical_import.py +++ b/apps/backend/tests/etl/test_historical_import.py @@ -1,239 +1,245 @@ -import hashlib -import json - -import pandas as pd -import pytest - -from app.etl.historical_import import ( - SOURCE_NAME, - build_reading_batch, - classify_quality, - compute_sha256, - load_metadata, - normalize_timestamps, - validate_source, -) - - -def make_metadata() -> dict: - return { - "total_records": 2, - "sites": { - "SITE001": {}, - }, - } - - -def make_dataframe() -> pd.DataFrame: - return pd.DataFrame( - [ - { - "timestamp": "2023-01-01 00:00:00", - "site_id": "SITE001", - "site_type": "office", - "site_name": "Site 1", - "consumption_kwh": 10.5, - "consumption_euros": 2.5, - "temperature_celsius": 20.0, - "humidity_percent": 50.0, - "solar_irradiance_wm2": 0.0, - "hour": 0, - "day_of_week": 6, - "day_name": "Sunday", - "month": 1, - "is_weekend": True, - "is_working_hours": False, - }, - { - "timestamp": "2023-01-01 01:00:00", - "site_id": "SITE001", - "site_type": "office", - "site_name": "Site 1", - "consumption_kwh": 11.0, - "consumption_euros": 2.7, - "temperature_celsius": 19.5, - "humidity_percent": 52.0, - "solar_irradiance_wm2": 0.0, - "hour": 1, - "day_of_week": 6, - "day_name": "Sunday", - "month": 1, - "is_weekend": True, - "is_working_hours": False, - }, - ] - ) - - -def test_compute_sha256(tmp_path): - file_path = tmp_path / "dataset.csv" - content = b"hello-enervision" - - file_path.write_bytes(content) - - expected = hashlib.sha256(content).hexdigest() - - assert compute_sha256(file_path) == expected - - -def test_load_metadata(tmp_path): - metadata_path = tmp_path / "metadata.json" - - metadata = { - "total_records": 2, - "sites": { - "SITE001": {}, - }, - } - - metadata_path.write_text( - json.dumps(metadata), - encoding="utf-8", - ) - - assert load_metadata(metadata_path) == metadata - - -def test_validate_source_accepts_valid_dataset(): - frame = make_dataframe() - - validate_source( - frame, - make_metadata(), - ) - - -def test_validate_source_rejects_missing_column(): - frame = make_dataframe().drop(columns=["consumption_kwh"]) - - with pytest.raises( - ValueError, - match="Colonnes obligatoires absentes", - ): - validate_source( - frame, - make_metadata(), - ) - - -def test_validate_source_rejects_duplicates(): - frame = make_dataframe() - - frame.loc[1, "timestamp"] = frame.loc[ - 0, - "timestamp", - ] - - with pytest.raises( - ValueError, - match="doublons", - ): - validate_source( - frame, - make_metadata(), - ) - - -def test_validate_source_rejects_unknown_site(): - frame = make_dataframe() - - frame.loc[1, "site_id"] = "SITE999" - - with pytest.raises( - ValueError, - match="Sites incohérents", - ): - validate_source( - frame, - make_metadata(), - ) - - -def test_normalize_timestamps_adds_timezone(): - frame = make_dataframe() - - normalized = normalize_timestamps( - frame, - "UTC", - ) - - assert normalized["timestamp"].dt.tz is not None - - assert "_source_timestamp" in normalized.columns - - -def test_classify_quality_good(): - row = make_dataframe().iloc[0].to_dict() - - quality, reasons = classify_quality(row) - - assert quality == "good" - assert reasons == [] - - -def test_classify_quality_degraded_when_consumption_missing(): - row = make_dataframe().iloc[0].to_dict() - row["consumption_kwh"] = None - - quality, reasons = classify_quality(row) - - assert quality == "degraded" - - assert "missing:consumption_kwh" in reasons - - -def test_build_reading_batch_respects_database_contract(): - frame = normalize_timestamps( - make_dataframe(), - "UTC", - ) - - rows = build_reading_batch( - frame.iloc[:1], - dataset_id=3, - ) - - assert len(rows) == 1 - - row = rows[0] - - assert row["dataset_id"] == 3 - - # Important : - # contrainte ck_reading_dataset_source. - assert row["source"] == "csv" - assert SOURCE_NAME == "csv" - - # Important : - # contrainte ck_reading_imputation. - assert row["imputed_values"] is None - assert row["imputation_method"] is None - - assert row["data_quality"] == "good" - assert row["null_reasons"] == [] - - -def test_build_reading_batch_keeps_missing_values(): - frame = make_dataframe() - - frame.loc[0, "temperature_celsius"] = None - - frame = normalize_timestamps( - frame, - "UTC", - ) - - rows = build_reading_batch( - frame.iloc[:1], - dataset_id=3, - ) - - row = rows[0] - - assert row["temperature_celsius"] is None - - assert "missing:temperature_celsius" in row["null_reasons"] - - # RAW ingestion : aucune imputation. - assert row["imputed_values"] is None - assert row["imputation_method"] is None +import hashlib +import json + +import pandas as pd +import pytest + +from app.etl.historical_import import ( + SOURCE_NAME, + build_reading_batch, + classify_quality, + compute_sha256, + load_metadata, + normalize_timestamps, + validate_source, +) + + +def make_metadata() -> dict: + return { + "total_records": 2, + "sites": { + "SITE001": {}, + }, + } + + +def make_dataframe() -> pd.DataFrame: + return pd.DataFrame( + [ + { + "timestamp": "2023-01-01 00:00:00", + "site_id": "SITE001", + "site_type": "office", + "site_name": "Site 1", + "consumption_kwh": 10.5, + "consumption_euros": 2.5, + "temperature_celsius": 20.0, + "humidity_percent": 50.0, + "solar_irradiance_wm2": 0.0, + "hour": 0, + "day_of_week": 6, + "day_name": "Sunday", + "month": 1, + "is_weekend": True, + "is_working_hours": False, + }, + { + "timestamp": "2023-01-01 01:00:00", + "site_id": "SITE001", + "site_type": "office", + "site_name": "Site 1", + "consumption_kwh": 11.0, + "consumption_euros": 2.7, + "temperature_celsius": 19.5, + "humidity_percent": 52.0, + "solar_irradiance_wm2": 0.0, + "hour": 1, + "day_of_week": 6, + "day_name": "Sunday", + "month": 1, + "is_weekend": True, + "is_working_hours": False, + }, + ] + ) + + +def test_compute_sha256(tmp_path): + file_path = tmp_path / "dataset.csv" + content = b"hello-enervision" + + file_path.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + + assert compute_sha256(file_path) == expected + + +def test_load_metadata(tmp_path): + metadata_path = tmp_path / "metadata.json" + + metadata = { + "total_records": 2, + "sites": { + "SITE001": {}, + }, + } + + metadata_path.write_text( + json.dumps(metadata), + encoding="utf-8", + ) + + assert load_metadata(metadata_path) == metadata + + +def test_validate_source_accepts_valid_dataset(): + frame = make_dataframe() + + validate_source( + frame, + make_metadata(), + ) + + +def test_validate_source_rejects_missing_column(): + frame = make_dataframe().drop(columns=["consumption_kwh"]) + + metadata = make_metadata() + + with pytest.raises( + ValueError, + match="Colonnes obligatoires absentes", + ): + validate_source( + frame, + metadata, + ) + + +def test_validate_source_rejects_duplicates(): + frame = make_dataframe() + + frame.loc[1, "timestamp"] = frame.loc[ + 0, + "timestamp", + ] + + metadata = make_metadata() + + with pytest.raises( + ValueError, + match="doublons", + ): + validate_source( + frame, + metadata, + ) + + +def test_validate_source_rejects_unknown_site(): + frame = make_dataframe() + + frame.loc[1, "site_id"] = "SITE999" + + metadata = make_metadata() + + with pytest.raises( + ValueError, + match="Sites incohérents", + ): + validate_source( + frame, + metadata, + ) + + +def test_normalize_timestamps_adds_timezone(): + frame = make_dataframe() + + normalized = normalize_timestamps( + frame, + "UTC", + ) + + assert normalized["timestamp"].dt.tz is not None + + assert "_source_timestamp" in normalized.columns + + +def test_classify_quality_good(): + row = make_dataframe().iloc[0].to_dict() + + quality, reasons = classify_quality(row) + + assert quality == "good" + assert reasons == [] + + +def test_classify_quality_degraded_when_consumption_missing(): + row = make_dataframe().iloc[0].to_dict() + row["consumption_kwh"] = None + + quality, reasons = classify_quality(row) + + assert quality == "degraded" + + assert "missing:consumption_kwh" in reasons + + +def test_build_reading_batch_respects_database_contract(): + frame = normalize_timestamps( + make_dataframe(), + "UTC", + ) + + rows = build_reading_batch( + frame.iloc[:1], + dataset_id=3, + ) + + assert len(rows) == 1 + + row = rows[0] + + assert row["dataset_id"] == 3 + + # Important : + # contrainte ck_reading_dataset_source. + assert row["source"] == "csv" + assert SOURCE_NAME == "csv" + + # Important : + # contrainte ck_reading_imputation. + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + assert row["data_quality"] == "good" + assert row["null_reasons"] == [] + + +def test_build_reading_batch_keeps_missing_values(): + frame = make_dataframe() + + frame.loc[0, "temperature_celsius"] = None + + frame = normalize_timestamps( + frame, + "UTC", + ) + + rows = build_reading_batch( + frame.iloc[:1], + dataset_id=3, + ) + + row = rows[0] + + assert row["temperature_celsius"] is None + + assert "missing:temperature_celsius" in row["null_reasons"] + + # RAW ingestion : aucune imputation. + assert row["imputed_values"] is None + assert row["imputation_method"] is None diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index cdcff55..fa654bc 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -283,6 +283,41 @@ def test_create_mock_api_client_requires_credentials( 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( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/apps/backend/tests/repositories/test_alert.py b/apps/backend/tests/repositories/test_alert.py index 16c5a9a..ba9ff08 100644 --- a/apps/backend/tests/repositories/test_alert.py +++ b/apps/backend/tests/repositories/test_alert.py @@ -5,6 +5,7 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert +from app.repositories import alert as module_alert from app.repositories.alert import AlertRepository from app.schemas.alert import AlertSeverity 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([]) 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 diff --git a/apps/backend/tests/repositories/test_audit_log.py b/apps/backend/tests/repositories/test_audit_log.py index beacc8e..9edbe5c 100644 --- a/apps/backend/tests/repositories/test_audit_log.py +++ b/apps/backend/tests/repositories/test_audit_log.py @@ -49,8 +49,10 @@ async def test_the_database_refuses_to_mutate_the_audit_log( ) -> None: await une_ligne(session) + requete = text(instruction) + with pytest.raises(DBAPIError, match="ajout seul"): - await session.execute(text(instruction)) + await session.execute(requete) await session.rollback() diff --git a/apps/backend/tests/repositories/test_drift.py b/apps/backend/tests/repositories/test_drift.py new file mode 100644 index 0000000..255abbe --- /dev/null +++ b/apps/backend/tests/repositories/test_drift.py @@ -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 diff --git a/apps/backend/tests/repositories/test_password_reset_token.py b/apps/backend/tests/repositories/test_password_reset_token.py index fe99800..eebbd21 100644 --- a/apps/backend/tests/repositories/test_password_reset_token.py +++ b/apps/backend/tests/repositories/test_password_reset_token.py @@ -131,11 +131,14 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( user_agent=None, ) + empreinte = fingerprint_refresh(secret) + expiration = datetime.now(UTC) + DUREE + with pytest.raises(IntegrityError): await depot.create( user_id=compte, - token_hash=fingerprint_refresh(secret), - expires_at=datetime.now(UTC) + DUREE, + token_hash=empreinte, + expires_at=expiration, client_ip=None, user_agent=None, ) diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index ac3f854..c62a3d2 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -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)), source=overrides.get("source", "api_current"), 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"), raw_data=overrides.get("raw_data", {}), ) diff --git a/apps/backend/tests/repositories/test_refresh_token.py b/apps/backend/tests/repositories/test_refresh_token.py index 73d82b4..f1adad8 100644 --- a/apps/backend/tests/repositories/test_refresh_token.py +++ b/apps/backend/tests/repositories/test_refresh_token.py @@ -178,12 +178,16 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( user_agent=None, ) + famille = uuid.uuid4() + empreinte = fingerprint_refresh(secret) + expiration = datetime.now(UTC) + DUREE + with pytest.raises(IntegrityError): await depot.create( user_id=compte, - family_id=uuid.uuid4(), - token_hash=fingerprint_refresh(secret), - expires_at=datetime.now(UTC) + DUREE, + family_id=famille, + token_hash=empreinte, + expires_at=expiration, client_ip=None, user_agent=None, ) diff --git a/apps/backend/tests/repositories/test_user.py b/apps/backend/tests/repositories/test_user.py index 0701a2d..e52284f 100644 --- a/apps/backend/tests/repositories/test_user.py +++ b/apps/backend/tests/repositories/test_user.py @@ -31,14 +31,12 @@ async def test_the_database_refuses_an_email_written_in_upper_case( ) -> None: saisie = adresse().upper() + requete = text( + "insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')" + ) + with pytest.raises(IntegrityError): - await session.execute( - text( - "insert into app_user (email, password_hash, role) " - "values (:e, '$argon2id$x', 'lecteur')" - ), - {"e": saisie}, - ) + await session.execute(requete, {"e": saisie}) await session.rollback() diff --git a/apps/backend/tests/services/test_drift.py b/apps/backend/tests/services/test_drift.py new file mode 100644 index 0000000..acc924a --- /dev/null +++ b/apps/backend/tests/services/test_drift.py @@ -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 "") diff --git a/apps/backend/tests/services/test_reading.py b/apps/backend/tests/services/test_reading.py index a3f0826..5718281 100644 --- a/apps/backend/tests/services/test_reading.py +++ b/apps/backend/tests/services/test_reading.py @@ -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: service = ReadingService(readings=FakeRepository([])) + debut = datetime(2026, 9, 2, tzinfo=UTC) + fin = datetime(2026, 9, 1, tzinfo=UTC) + with pytest.raises(FenetreInverseeError): - await service.list_history( - start=datetime(2026, 9, 2, tzinfo=UTC), - end=datetime(2026, 9, 1, tzinfo=UTC), - limit=500, - offset=0, - ) + await service.list_history(start=debut, end=fin, limit=500, offset=0) async def test_list_history_raises_when_start_equals_end() -> None: diff --git a/apps/backend/tests/services/test_user.py b/apps/backend/tests/services/test_user.py index acb9463..0cd2d0c 100644 --- a/apps/backend/tests/services/test_user.py +++ b/apps/backend/tests/services/test_user.py @@ -235,5 +235,7 @@ async def test_every_operation_refuses_an_unknown_account(action: str) -> None: if action == "set_active": arguments["is_active"] = False + methode = getattr(attirail.service, action) + with pytest.raises(UserNotFoundError): - await getattr(attirail.service, action)(**arguments) + await methode(**arguments) diff --git a/apps/backend/tests/test_chaine_ml_api.py b/apps/backend/tests/test_chaine_ml_api.py new file mode 100644 index 0000000..f2718a5 --- /dev/null +++ b/apps/backend/tests/test_chaine_ml_api.py @@ -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"] diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 2edf814..530efee 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -19,13 +19,17 @@ def test_build_parser_reads_the_create_admin_arguments() -> None: def test_build_parser_requires_a_subcommand() -> None: + parser = cli.build_parser() + with pytest.raises(SystemExit): - cli.build_parser().parse_args([]) + parser.parse_args([]) def test_build_parser_requires_an_email() -> None: + parser = cli.build_parser() + 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( diff --git a/apps/backend/tests/test_drift_cli.py b/apps/backend/tests/test_drift_cli.py new file mode 100644 index 0000000..4ece630 --- /dev/null +++ b/apps/backend/tests/test_drift_cli.py @@ -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 diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index a8b434f..7b67e36 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -655,31 +655,31 @@ wheels = [ [[package]] name = "pandas" -version = "3.0.5" +version = "3.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, { 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 = [ - { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] @@ -936,27 +936,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.7" +version = "0.16.8" 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 = [ - { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 43e3634..53ac72c 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -3,7 +3,7 @@ # ================== # Image pour frontend -FROM node:24-alpine3.22 AS builder +FROM node:26-alpine3.22 AS builder WORKDIR /app @@ -23,7 +23,7 @@ RUN npm run build # ================== -FROM nginx:1.28.0-alpine3.21 AS runner +FROM nginx:1.31-alpine AS runner # Copie de la configuration de nginx COPY --chown=root:root nginx.conf /etc/nginx/nginx.conf diff --git a/apps/frontend/TESTING.md b/apps/frontend/TESTING.md index d4e92bf..e87dbaf 100644 --- a/apps/frontend/TESTING.md +++ b/apps/frontend/TESTING.md @@ -1,4 +1,4 @@ -# Conventions de tests unitaires — Frontend +# Conventions de tests unitaires : Frontend ## Outil Vitest (intégré nativement à Angular CLI, pas d'installation à faire). @@ -83,3 +83,6 @@ describe('MonComposant', () => { ## Lancer les tests - Développement (mode watch) : `npm test` - 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) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index a60cacb..e39c96c 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -8,12 +8,12 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@angular/common": "^22.1.0", - "@angular/compiler": "^22.1.0", - "@angular/core": "^22.1.0", - "@angular/forms": "^22.1.0", - "@angular/platform-browser": "^22.1.0", - "@angular/router": "^22.1.0", + "@angular/common": "^22.1.7", + "@angular/compiler": "^22.1.7", + "@angular/core": "^22.1.7", + "@angular/forms": "^22.1.7", + "@angular/platform-browser": "^22.1.7", + "@angular/router": "^22.1.7", "chart.js": "^4.5.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -21,21 +21,14 @@ "devDependencies": { "@angular/build": "^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", - "jsdom": "^28.0.0", - "prettier": "^3.8.1", + "jsdom": "^30.1.0", + "prettier": "^3.9.8", "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -255,9 +248,9 @@ } }, "node_modules/@angular/common": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.6.tgz", - "integrity": "sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==", + "version": "22.1.7", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.7.tgz", + "integrity": "sha512-SLW4AdH2Y8G9SM/iFRNpCQtkmvq55RcIQWsADT9ypZ7FxxcnJRsr4Q/WnbUBbuCslsCkrgzU1i9WDiW0m7gcnw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -266,14 +259,14 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/core": "22.1.6", + "@angular/core": "22.1.7", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.6.tgz", - "integrity": "sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==", + "version": "22.1.7", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.7.tgz", + "integrity": "sha512-qhoHAByh805nT4Xy4Wd2kE5FvkTc7wcg8Qps+EH6+z4KRfwGiL5cwdgooGWjTAldFCeqTgJ0BO/0dB6GHZjdkQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -283,9 +276,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.6.tgz", - "integrity": "sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==", + "version": "22.1.7", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.7.tgz", + "integrity": "sha512-VGg8Kyt0YymQfnUUFk79vFaM+x5xmIMHy+BZh3P2Lh/uxqCXTWhJJH8kOHKtIGGH5/wKhBEmSy2CezdcIuM9xg==", "dev": true, "license": "MIT", "dependencies": { @@ -306,7 +299,7 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/compiler": "22.1.6", + "@angular/compiler": "22.1.7", "typescript": ">=6.0 <6.1" }, "peerDependenciesMeta": { @@ -316,9 +309,9 @@ } }, "node_modules/@angular/core": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz", - "integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==", + "version": "22.1.7", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.7.tgz", + "integrity": "sha512-T9bt1PIgoN7n+TxGHTUgu0EsomlGMQ84YMXbMB+N6HhD8C1qZxn0uCbnAtCUjcecg1am8ioKxNTO0eII9ouioQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -327,7 +320,7 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/compiler": "22.1.6", + "@angular/compiler": "22.1.7", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -341,9 +334,9 @@ } }, "node_modules/@angular/forms": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.6.tgz", - "integrity": "sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==", + "version": "22.1.7", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.7.tgz", + "integrity": "sha512-oc0DT39C3ZboJpDx8xKUCltgn56LHi0kbv+ThNK/dCSZLgjF2nd+muMMRYF+amdljQp2q7+hm2ORmhON3CNFog==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -354,16 +347,16 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/common": "22.1.6", - "@angular/core": "22.1.6", - "@angular/platform-browser": "22.1.6", + "@angular/common": "22.1.7", + "@angular/core": "22.1.7", + "@angular/platform-browser": "22.1.7", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.6.tgz", - "integrity": "sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==", + "version": "22.1.7", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.7.tgz", + "integrity": "sha512-4dT3qGw3ZHVQUcHKALzMx9MTo+pxAMzHIPxc8HUESklqXfq6GRaIIfFMvE0RsquI9iqu6admD6lOAEzLWS8DxA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -372,9 +365,9 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/animations": "22.1.6", - "@angular/common": "22.1.6", - "@angular/core": "22.1.6" + "@angular/animations": "22.1.7", + "@angular/common": "22.1.7", + "@angular/core": "22.1.7" }, "peerDependenciesMeta": { "@angular/animations": { @@ -383,9 +376,9 @@ } }, "node_modules/@angular/router": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.6.tgz", - "integrity": "sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==", + "version": "22.1.7", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.7.tgz", + "integrity": "sha512-CxYa2Ym5L0WmqToHU2I0Z4VEpocdryLlvx5svLfo9M3uOaP3/9icB/gjS/vXPbbQxV8/GZ59E/7/LF14anIoRg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -394,60 +387,45 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/common": "22.1.6", - "@angular/core": "22.1.6", - "@angular/platform-browser": "22.1.6", + "@angular/common": "22.1.7", + "@angular/core": "22.1.7", + "@angular/platform-browser": "22.1.7", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz", + "integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.2.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": { - "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": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz", + "integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" - } - }, - "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", + "lru-cache": "^11.5.2" + }, "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": { "version": "8.0.0", "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": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", - "integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", "dev": true, "funding": [ { @@ -879,9 +857,9 @@ } }, "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", "dev": true, "funding": [ { @@ -4490,22 +4468,6 @@ "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": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -5283,30 +5245,6 @@ "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": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", @@ -5529,39 +5467,38 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz", + "integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.1.2", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.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", - "parse5": "^8.0.0", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^17.1.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^3.2.3" }, "peerDependenciesMeta": { "canvas": { @@ -5569,28 +5506,19 @@ } } }, - "node_modules/jsdom/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/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==", + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" }, "engines": { - "node": ">= 14" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/jsesc": { @@ -6708,9 +6636,9 @@ } }, "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz", + "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", "dev": true, "license": "MIT", "bin": { @@ -7308,13 +7236,6 @@ "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": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -7469,13 +7390,13 @@ } }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/unpipe": { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 047e5a1..9c92c9a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -12,12 +12,12 @@ "private": true, "packageManager": "npm@11.19.0", "dependencies": { - "@angular/common": "^22.1.0", - "@angular/compiler": "^22.1.0", - "@angular/core": "^22.1.0", - "@angular/forms": "^22.1.0", - "@angular/platform-browser": "^22.1.0", - "@angular/router": "^22.1.0", + "@angular/common": "^22.1.7", + "@angular/compiler": "^22.1.7", + "@angular/core": "^22.1.7", + "@angular/forms": "^22.1.7", + "@angular/platform-browser": "^22.1.7", + "@angular/router": "^22.1.7", "chart.js": "^4.5.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -25,11 +25,11 @@ "devDependencies": { "@angular/build": "^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", - "jsdom": "^28.0.0", - "prettier": "^3.8.1", + "jsdom": "^30.1.0", + "prettier": "^3.9.8", "typescript": "~6.0.2", - "vitest": "^4.0.8" + "vitest": "^4.1.11" } } diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 40e814f..d2f1079 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,21 +1,36 @@ import { Routes } from '@angular/router'; -import {authGuard} from './core/guards/auth-guard'; +import { authGuard } from './core/guards/auth-guard'; export const routes: Routes = [ { 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: '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: '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: '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', canActivate: [authGuard], - loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard), + loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard), }, { path: 'sites', 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', @@ -23,6 +38,12 @@ export const routes: Routes = [ loadComponent: () => 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], diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts index 4483313..58eaae6 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -8,8 +8,10 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture'; describe('mockApiInterceptor', () => { let http: HttpClient; let httpMock: HttpTestingController; + let useMockFixturesInitial: boolean; beforeEach(() => { + useMockFixturesInitial = environment.useMockFixtures; TestBed.configureTestingModule({ providers: [ provideHttpClient(withInterceptors([mockApiInterceptor])), @@ -21,7 +23,7 @@ describe('mockApiInterceptor', () => { }); afterEach(() => { - environment.useMockFixtures = true; + environment.useMockFixtures = useMockFixturesInitial; httpMock.verify(); }); diff --git a/apps/frontend/src/app/core/mocks/alerts.fixture.ts b/apps/frontend/src/app/core/mocks/alerts.fixture.ts index c1f7a9a..84c8c66 100644 --- a/apps/frontend/src/app/core/mocks/alerts.fixture.ts +++ b/apps/frontend/src/app/core/mocks/alerts.fixture.ts @@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model'; export const ALERTS_FIXTURE: Alert[] = [ { - alert_id: 'ALR-SITE002-1718458320', - timestamp: '2026-09-15T11:12:00', + alert_id: 5, site_id: 'SITE002', + timestamp: '2026-09-15T11:12:00Z', + type: 'threshold', severity: 'critical', - type: 'outage', - message: 'Risque de surcharge sur Usine Lyon Vénissieux', + message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)', value: 812.5, threshold: 720.0, + metric: 'consumption_kw', + prediction_id: null, }, { - alert_id: 'ALR-SITE003-1718458321', - timestamp: '2026-09-15T11:05:00', + alert_id: 4, site_id: 'SITE003', + timestamp: '2026-09-15T11:05:00Z', + type: 'outage', severity: 'critical', - type: 'sensor', - message: 'Perte réseau totale sur Data Center Marseille', - value: 0, - threshold: 0, + message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)', + value: null, + threshold: null, + metric: null, + prediction_id: null, }, { - alert_id: 'ALR-SITE005-1718458322', - timestamp: '2026-09-15T10:47:00', + alert_id: 3, site_id: 'SITE005', + timestamp: '2026-09-15T10:47:00Z', + type: 'spike', severity: 'high', - type: 'threshold', - message: 'Usine Toulouse approche de son seuil de capacité', + message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)', value: 410.0, - threshold: 480.0, + threshold: 260.0, + metric: 'consumption_kw', + prediction_id: null, }, { - alert_id: 'ALR-SITE006-1718458323', - timestamp: '2026-09-15T10:30:00', + alert_id: 2, site_id: 'SITE006', - severity: 'medium', + timestamp: '2026-09-15T10:30:00Z', type: 'sensor', - message: 'Capteur de température défaillant sur Bureau Lille', - value: 0, - threshold: 0, + severity: 'medium', + message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)', + value: null, + threshold: null, + metric: null, + prediction_id: null, }, { - alert_id: 'ALR-SITE004-1718458324', - timestamp: '2026-09-15T09:58:00', + alert_id: 1, site_id: 'SITE004', - severity: 'low', + timestamp: '2026-09-15T09:58:00Z', 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, threshold: 55.0, + metric: 'consumption_kwh', + prediction_id: 42, }, ]; diff --git a/apps/frontend/src/app/core/services/alerts.service.spec.ts b/apps/frontend/src/app/core/services/alerts.service.spec.ts index 68b5740..c17bc4b 100644 --- a/apps/frontend/src/app/core/services/alerts.service.spec.ts +++ b/apps/frontend/src/app/core/services/alerts.service.spec.ts @@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { AlertsService } from './alerts.service'; 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', () => { let service: AlertsService; @@ -18,26 +32,35 @@ describe('AlertsService', () => { afterEach(() => httpMock.verify()); - it("appelle le bon endpoint et retourne un tableau d'alertes", () => { - let result: unknown; + it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => { + let result: Alert[] = []; service.getAlerts().subscribe((r) => (result = r)); - const req = httpMock.expectOne(`${environment.apiUrl}/alerts`); - expect(req.request.method).toBe('GET'); + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET', + ); + expect(req.request.params.keys()).toEqual([]); + req.flush([ALERT_API]); - req.flush([ - { - alert_id: 'ALR-TEST-1', - timestamp: '2026-09-15T12:00:00', - site_id: 'SITE001', - severity: 'high', - type: 'threshold', - message: 'Test', - value: 100, - threshold: 90, - }, - ]); + expect(result.length).toBe(1); + expect(result[0].alert_id).toBe(1); + expect(result[0].prediction_id).toBeNull(); + }); - 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([]); }); }); diff --git a/apps/frontend/src/app/core/services/alerts.service.ts b/apps/frontend/src/app/core/services/alerts.service.ts index ebd00e2..8d9a8bf 100644 --- a/apps/frontend/src/app/core/services/alerts.service.ts +++ b/apps/frontend/src/app/core/services/alerts.service.ts @@ -1,13 +1,25 @@ 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 { Alert } from '../../shared/models/alert.model'; +import { Alert, AlertSeverity } from '../../shared/models/alert.model'; + +export interface AlertFilters { + site_id?: string; + severity?: AlertSeverity; +} @Service() export class AlertsService { private http = inject(HttpClient); - getAlerts() { - return this.http.get(`${environment.apiUrl}/alerts`); + getAlerts(filters: AlertFilters = {}) { + 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(`${environment.apiUrl}/alerts`, { params }); } } diff --git a/apps/frontend/src/app/core/services/recommendations.service.spec.ts b/apps/frontend/src/app/core/services/recommendations.service.spec.ts new file mode 100644 index 0000000..4252757 --- /dev/null +++ b/apps/frontend/src/app/core/services/recommendations.service.spec.ts @@ -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 }); + }); +}); diff --git a/apps/frontend/src/app/core/services/recommendations.service.ts b/apps/frontend/src/app/core/services/recommendations.service.ts new file mode 100644 index 0000000..7a51844 --- /dev/null +++ b/apps/frontend/src/app/core/services/recommendations.service.ts @@ -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(`${environment.apiUrl}/recommendations`); + } + + getRecommendation(recommendationId: number) { + return this.http.get( + `${environment.apiUrl}/recommendations/${recommendationId}`, + ); + } + + generate(siteId?: string) { + let params = new HttpParams(); + if (siteId) { + params = params.set('site_id', siteId); + } + return this.http.post( + `${environment.apiUrl}/recommendations/generate`, + null, + { params }, + ); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index f9a3fb2..fd84e27 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -14,6 +14,7 @@ Supervision des capteurs } Voir les sites + Recommandations {{ message }} } - @if (alertsError(); as message) { - - } @if (predictionsError(); as message) { } @if (stats(); as s) { -
- - Consommation vs capacité +

+ + Actualisé à {{ s.timestamp | date: 'HH:mm:ss' }} · {{ s.total_sites }} sites suivis +

+ +
+ + Consommation vs capacité - {{ s.total_consumption_kw | number: '1.0-1' }} / - {{ s.total_capacity_kw | number }} kW + {{ s.total_consumption_kw | number: '1.0-1' }} + / {{ s.total_capacity_kw | number }} kW + + + + + Charge moyenne du parc + {{ s.average_load_percent | number: '1.0-0' }} % - - - - Charge moyenne du parc - {{ s.average_load_percent }} % -
-
+
+
+ {{ loadHint(s.average_load_percent) }} - - Sites suivis - {{ s.total_sites }} + + Sites suivis + {{ s.total_sites }} + Voir la liste des sites
- -
-

Charge et alerte visuelle par site

- -
} - @if (alerts().length > 0) { -
-

Alertes actives

-
    - @for (alert of alerts(); track alert.alert_id) { -
  • - {{ alert.severity }} - {{ alert.message }} -
  • +
    +
    + @if (stats(); as s) { +
    +

    Charge par site

    + + + +
    + } + +
    +

    Prévisions de consommation

    + @if (predictions().length > 0) { + + + + + + + + + + + + @for (site of predictions(); track site.site_id) { + + + @if (site.prediction; as prediction) { + @if (prediction.status === 'available') { + + + } @else { + + + } + } @else { + + + } + + + } + +
    SitePrévisionÉchéance
    {{ site.site_name }} + {{ prediction.predicted_value | number: '1.0-1' }} kWh + {{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} + {{ + prediction.status === 'insufficient_data' + ? 'Historique insuffisant' + : 'Erreur' + }} + -Pas encore de prévision-Détail
    +
    + } @else if (!predictionsError()) { +

    Aucune prévision disponible pour le moment.

    } -
-
- } +
+ - @if (predictions().length > 0) { -
-

Prévisions de consommation

-
    - @for (site of predictions(); track site.site_id) { -
  • - {{ site.site_name }} - @if (site.prediction; as prediction) { - @if (prediction.status === 'available') { - - {{ prediction.predicted_value | number: '1.0-1' }} kWh - {{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} - - } @else { - {{ - prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur' - }} - } - } @else { - Pas encore de prévision - } -
  • - } -
-
- } + + diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 9c89f56..c3f60bb 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -8,9 +8,11 @@ .dashboard__header { display: flex; + flex-wrap: wrap; align-items: flex-start; justify-content: space-between; - margin-bottom: 2rem; + gap: var(--space-3); + margin-bottom: var(--space-3); } .dashboard__brand { @@ -20,8 +22,9 @@ h1 { margin: 0; - font-size: 1.75rem; + font-size: var(--font-size-xl); font-weight: 700; + letter-spacing: -0.01em; } } @@ -31,19 +34,69 @@ .dashboard__subtitle { margin: 0.25rem 0 0; + font-size: var(--font-size-sm); color: var(--color-text-muted); } .dashboard__actions { display: flex; + flex-wrap: wrap; 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 { - font-size: 1.1rem; + margin: 0 0 var(--space-3); + font-size: var(--font-size-lg); font-weight: 600; - margin: 0 0 1rem; } .banner-error { @@ -54,116 +107,124 @@ h2 { .overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; - margin-bottom: 2.5rem; + gap: var(--space-3); + margin-bottom: var(--space-5); } -.card { - padding: 1.25rem; - gap: 0.35rem; +.kpi { + position: relative; + 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; text-align: center; } -.card--link { - cursor: pointer; - transition: border-color 0.15s ease; +.kpi__label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} - &:hover { - border-color: var(--color-primary); +.kpi__value { + font-size: var(--font-size-2xl); + 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); } } -.card__label { - font-size: 0.8rem; +.kpi__hint { + font-size: var(--font-size-xs); color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.02em; } -.card__value { - font-size: 1.6rem; - font-weight: 700; +.kpi__link { + margin-top: auto; + font-size: var(--font-size-sm); } .progress-bar { - height: 6px; + height: 8px; + margin: var(--space-1) 0; background: var(--color-border-light); border-radius: var(--radius-pill); overflow: hidden; - margin-top: 0.25rem; } .progress-bar__fill { height: 100%; - background: var(--color-primary); border-radius: var(--radius-pill); + background: var(--color-success); 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 { - margin-bottom: 2.5rem; + margin-bottom: var(--space-5); } -.alerts-list { - list-style: none; +.chart-card { + padding: var(--space-3); +} + +.dashboard__empty { margin: 0; - padding: 0; - 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; + font-size: var(--font-size-sm); color: var(--color-text-muted); } + +@media (max-width: 900px) { + .dashboard__grid { + grid-template-columns: 1fr; + } + + .dashboard__side { + position: static; + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 910b0f6..e8a696e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -1,12 +1,13 @@ import { TestBed } from '@angular/core/testing'; 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 { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; +import { SitesService } from '../../core/services/sites.service'; import { PredictionsService } from '../../core/services/predictions.service'; -import {AuthService} from '../../core/services/auth.service'; -import {Router, provideRouter} from '@angular/router'; +import { AuthService } from '../../core/services/auth.service'; vi.mock('chart.js', () => { class ChartMock { @@ -18,66 +19,73 @@ vi.mock('chart.js', () => { return { Chart: ChartMock, registerables: [] }; }); +const STATS = { total_sites: 7, sites: [] }; + 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; + predictions?: { getPredictions: ReturnType }; + auth?: Record; + } = {}, +) { + 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', () => { afterEach(() => vi.useRealTimers()); - it('charge les stats, les alertes et les prévisions au démarrage', async () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; - const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]); - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictions }, - provideRouter([]), - ], + it('charge les stats et les prévisions au démarrage', async () => { + const { fixture, statsMock, predictions } = setup({ + predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]), }); - const fixture = TestBed.createComponent(Dashboard); fixture.detectChanges(); - - // laisse le timer(0, ...) se déclencher avant de vérifier await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(statsMock.getSummary).toHaveBeenCalled(); - expect(alertsMock.getAlerts).toHaveBeenCalled(); expect(predictions.getPredictions).toHaveBeenCalled(); - expect(fixture.componentInstance.alerts().length).toBe(1); expect(fixture.componentInstance.predictions().length).toBe(1); expect(fixture.componentInstance.statsError()).toBeNull(); - expect(fixture.componentInstance.alertsError()).toBeNull(); expect(fixture.componentInstance.predictionsError()).toBeNull(); }); + 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", () => { vi.useFakeTimers(); - const statsMock = { - getSummary: vi - .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([]), - ], + const { fixture, statsMock } = setup({ + stats: throwError(() => new Error('API injoignable')), }); + statsMock.getSummary + .mockReturnValueOnce(throwError(() => new Error('API injoignable'))) + .mockReturnValue(of(STATS)); - const fixture = TestBed.createComponent(Dashboard); fixture.detectChanges(); vi.advanceTimersByTime(1); @@ -91,45 +99,11 @@ describe('Dashboard', () => { 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", () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - const predictions = { - getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), - }; - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictions }, - provideRouter([]), - ], + const { fixture } = setup({ + predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, }); - const fixture = TestBed.createComponent(Dashboard); fixture.detectChanges(); 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", () => { vi.useFakeTimers(); - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - const predictions = { - getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), - }; - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictions }, - provideRouter([]), - ], + const { fixture } = setup({ + predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, }); - const fixture = TestBed.createComponent(Dashboard); fixture.detectChanges(); - expect(fixture.componentInstance.predictionsError()).not.toBeNull(); // Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit @@ -168,109 +128,106 @@ describe('Dashboard', () => { }); it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - const authMock = { - logout: vi.fn().mockReturnValue(of(undefined)), - clearSession: vi.fn(), - principal: vi.fn().mockReturnValue({ role: 'admin' }), - }; - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { 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', () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; const authMock = { - logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), - clearSession: vi.fn(), - principal: vi.fn().mockReturnValue({ role: 'admin' }), - }; - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictionsMock() }, - { provide: AuthService, useValue: authMock }, - provideRouter([]), - ], + logout: vi.fn().mockReturnValue(of(undefined)), + 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); + + fixture.nativeElement.querySelector('.logout-button').click(); + + expect(authMock.logout).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); }); - const fixture = TestBed.createComponent(Dashboard); - fixture.detectChanges(); + it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { + const authMock = { + logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), + 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); - const router = TestBed.inject(Router); - const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + fixture.nativeElement.querySelector('.logout-button').click(); - const button = fixture.nativeElement.querySelector('.logout-button'); - button.click(); + expect(authMock.clearSession).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); + }); - expect(authMock.clearSession).toHaveBeenCalled(); - 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([]), - ], + it('affiche l’heure du dernier relevé et les indicateurs du parc', () => { + vi.useFakeTimers(); + const { fixture } = setup({ + stats: of({ + timestamp: '2026-09-18T09:00:00Z', + total_sites: 7, + total_consumption_kw: 1234.5, + total_capacity_kw: 5000, + average_load_percent: 24.7, + sites: [], + }), }); - 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; - expect(dashboard.badgeToneForSeverity('low')).toBe('success'); - expect(dashboard.badgeToneForSeverity('medium')).toBe('warning'); - expect(dashboard.badgeToneForSeverity('high')).toBe('danger'); - expect(dashboard.badgeToneForSeverity('critical')).toBe('critical'); - expect(dashboard.badgeToneForSeverity('high')).not.toBe( - dashboard.badgeToneForSeverity('critical'), - ); + expect(dashboard.loadTone(69.9)).toBe('success'); + expect(dashboard.loadTone(70)).toBe('warning'); + expect(dashboard.loadTone(89.9)).toBe('warning'); + expect(dashboard.loadTone(90)).toBe('danger'); + expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc'); }); it('distingue le ton des statuts de prévision', () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictionsMock() }, - provideRouter([]), - ], - }); - - const fixture = TestBed.createComponent(Dashboard); + const { fixture } = setup(); const dashboard = fixture.componentInstance; expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success'); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index 2ba20c0..2d9e1d6 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -6,11 +6,10 @@ import { Router, RouterLink } from '@angular/router'; import { StatsService } from '../../core/services/stats.service'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; -import { AlertsService } from '../../core/services/alerts.service'; +import { AlertFeed } from '../../shared/components/alert-feed/alert-feed'; import { PredictionsService } from '../../core/services/predictions.service'; import { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; -import { Alert, AlertSeverity } from '../../shared/models/alert.model'; import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model'; import { Card } from '../../shared/components/ui/card/card'; import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; @@ -22,22 +21,19 @@ const REFRESH_INTERVAL_MS = 10000; const UNAVAILABLE_MESSAGE = 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; -const TON_PAR_SEVERITE: Record = { - low: 'success', - 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. +// `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` : +// mieux vaut un ton défini que `undefined` le jour où ce statut apparaît. const TON_PAR_STATUT_PREDICTION: Record = { available: 'success', insufficient_data: 'warning', error: 'danger', }; +const SEUIL_CHARGE_SOUTENUE = 70; +const SEUIL_CHARGE_CRITIQUE = 90; + +export type LoadTone = 'success' | 'warning' | 'danger'; + @Component({ selector: 'app-dashboard', standalone: true, @@ -47,6 +43,7 @@ const TON_PAR_STATUT_PREDICTION: Record = { RouterLink, ConsumptionGauge, SiteLoadChart, + AlertFeed, Card, EvAlert, Badge, @@ -58,32 +55,20 @@ const TON_PAR_STATUT_PREDICTION: Record = { }) export class Dashboard implements OnInit { private statsService = inject(StatsService); - private alertsService = inject(AlertsService); public auth = inject(AuthService); private predictionsService = inject(PredictionsService); private router = inject(Router); private destroyRef = inject(DestroyRef); stats = signal(null); - alerts = signal([]); predictions = signal([]); - // Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats) - // efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus, - // sans retry ni indication pour l'utilisateur que la section correspondante est restée vide. + // Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en + // silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur. statsError = signal(null); - alertsError = signal(null); predictionsError = signal(null); ngOnInit(): void { - this.alertsService - .getAlerts() - .pipe(catchError(() => this.reportUnavailable(this.alertsError))) - .subscribe((alerts) => { - this.alertsError.set(null); - this.alerts.set(alerts); - }); - // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. this.predictionsService @@ -99,7 +84,9 @@ export class Dashboard implements OnInit { timer(0, REFRESH_INTERVAL_MS) .pipe( switchMap(() => - this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))), + this.statsService + .getSummary() + .pipe(catchError(() => this.reportUnavailable(this.statsError))), ), 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 { 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 { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), diff --git a/apps/frontend/src/app/features/recommendations/recommendations.html b/apps/frontend/src/app/features/recommendations/recommendations.html new file mode 100644 index 0000000..8a7747e --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.html @@ -0,0 +1,58 @@ +
+ + +
+ + +
+

Recommandations

+

+ Actions proposées par le moteur de règles à partir des alertes +

+
+
+ +
+ + @if (isAdmin()) { + + {{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }} + + } +
+ + @if (generationReport(); as report) { + {{ bilan(report) }}. + } + @if (generationError(); as message) { + {{ message }} + } + + @if (alertId(); as id) { +

+ Alerte n° {{ id }} · + Toutes les recommandations +

+ } + + +
diff --git a/apps/frontend/src/app/features/recommendations/recommendations.scss b/apps/frontend/src/app/features/recommendations/recommendations.scss new file mode 100644 index 0000000..ba95139 --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.scss @@ -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); +} diff --git a/apps/frontend/src/app/features/recommendations/recommendations.spec.ts b/apps/frontend/src/app/features/recommendations/recommendations.spec.ts new file mode 100644 index 0000000..dcb8e62 --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.spec.ts @@ -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; 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['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'); + }); +}); diff --git a/apps/frontend/src/app/features/recommendations/recommendations.ts b/apps/frontend/src/app/features/recommendations/recommendations.ts new file mode 100644 index 0000000..985c5af --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.ts @@ -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(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(null); + generationError = signal(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(', '); + } +} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html index 9c4a4bc..e10d1db 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.html +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -81,5 +81,16 @@ } } +
+

Recommandations

+ + Voir dans la vue recommandations +
+ Retour aux sites diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss index a0032be..5cfcad0 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -117,3 +117,12 @@ h2 { .chart-section { margin-bottom: 2rem; } + +.recommendations-section { + margin: 2.5rem 0 1.5rem; +} + +.recommendations-section__link { + display: inline-block; + margin-top: 1rem; +} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts index 5e3cf46..0e7c35a 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -5,6 +5,8 @@ import { BehaviorSubject, of, throwError } from 'rxjs'; import { SiteDetail } from './site-detail'; import { SitesService } from '../../../core/services/sites.service'; import { ReadingsService } from '../../../core/services/readings.service'; +import { AlertsService } from '../../../core/services/alerts.service'; +import { RecommendationsService } from '../../../core/services/recommendations.service'; const SITE = { site_id: 'SITE001', @@ -69,6 +71,7 @@ function setup( readingsMock: Partial, ) { const paramMap = new BehaviorSubject(convertToParamMap({ siteId })); + const getAlerts = vi.fn().mockReturnValue(of([])); TestBed.configureTestingModule({ imports: [SiteDetail], providers: [ @@ -76,9 +79,14 @@ function setup( { provide: ActivatedRoute, useValue: { paramMap } }, { provide: SitesService, useValue: sitesMock }, { 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', () => { @@ -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", () => { const getHistory = vi.fn().mockReturnValue(of([])); const { fixture } = setup( diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 21e716d..4fa7896 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -13,6 +13,7 @@ import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge'; import { Brand } from '../../../shared/components/ui/brand/brand'; import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge'; 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 NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.'; @@ -96,7 +97,16 @@ export interface MetricView { @Component({ selector: 'app-site-detail', standalone: true, - imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart], + imports: [ + RouterLink, + Card, + Alert, + Badge, + Brand, + ConsumptionGauge, + ReadingHistoryChart, + RecommendationList, + ], templateUrl: './site-detail.html', styleUrl: './site-detail.scss', }) @@ -117,6 +127,11 @@ export class SiteDetail { hasMeasurement = computed(() => this.current()?.timestamp != null); + siteAsList = computed(() => { + const site = this.site(); + return site ? [site] : []; + }); + consumptionKw = computed(() => this.current()?.consumption_kw ?? null); consumptionLabel = computed(() => { diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.html b/apps/frontend/src/app/features/sites/site-list/site-list.html index 9998066..9e8576c 100644 --- a/apps/frontend/src/app/features/sites/site-list/site-list.html +++ b/apps/frontend/src/app/features/sites/site-list/site-list.html @@ -17,8 +17,8 @@ } - - + +
@@ -36,7 +36,9 @@ - + } diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.scss b/apps/frontend/src/app/features/sites/site-list/site-list.scss index 9fa25a2..77c1923 100644 --- a/apps/frontend/src/app/features/sites/site-list/site-list.scss +++ b/apps/frontend/src/app/features/sites/site-list/site-list.scss @@ -32,32 +32,3 @@ display: block; 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; - } -} diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html new file mode 100644 index 0000000..12cb095 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html @@ -0,0 +1,95 @@ +
+
+
+

Alertes actives

+ @if (!loading() || alerts().length > 0) { +

+ {{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }} +

+ } +
+
+ @if (!siteId()) { + + } + +
+
+ + @if (error(); as message) { + {{ message }} + } + + @if (loading() && alerts().length === 0 && !error()) { +

Chargement des alertes…

+ } @else if (alerts().length === 0 && !error()) { + Aucune alerte pour ces critères. + } + +
    + @for (alert of visibleAlerts(); track alert.alert_id) { +
  • + + + +
    +
    + {{ + severityLabel(alert.severity) + }} + {{ typeLabel(alert.type) }} + {{ siteName(alert.site_id) }} + +
    +

    {{ alert.message }}

    + @if (alert.value !== null) { +

    + {{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }} + @if (alert.threshold !== null) { + seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }} + } +

    + } +
    +
  • + } +
+ + @if (hiddenCount() > 0) { + + Afficher plus ({{ hiddenCount() }} restantes) + + } +
diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss new file mode 100644 index 0000000..f5aa194 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss @@ -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); +} diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts new file mode 100644 index 0000000..e2068ec --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts @@ -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 { + 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 }, + sitesMock: { getSites: ReturnType } = { + 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) { + fixture.detectChanges(); + vi.advanceTimersByTime(1); + fixture.detectChanges(); +} + +function texte(fixture: ComponentFixture): string { + return (fixture.nativeElement as HTMLElement).textContent ?? ''; +} + +function choisir(fixture: ComponentFixture, 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'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts new file mode 100644 index 0000000..91f5d42 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts @@ -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(null); + + readonly severites = SEVERITES; + severity = signal(null); + siteFilter = signal(null); + + alerts = signal([]); + loading = signal(true); + error = signal(null); + visibleCount = signal(PAGE_SIZE); + + sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), { + initialValue: [] as Site[], + }); + + private filters = computed(() => ({ + 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 { + this.loading.set(false); + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html new file mode 100644 index 0000000..d844bdc --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html @@ -0,0 +1,48 @@ +@if (error(); as message) { + {{ message }} +} @else if (loading() && !hasData()) { +

Chargement des recommandations…

+} @else if (visibleGroups().length === 0) { + {{ emptyMessage() }} +} + +
+ @for (group of visibleGroups(); track group.alert.alert_id) { + +
+
+ {{ + severityLabel(group.alert.severity) + }} + {{ typeLabel(group.alert.type) }} + @if (!siteId()) { + {{ + group.siteName + }} + } + +
+

{{ group.alert.message }}

+
+
    + @for (reco of group.recommendations; track reco.recommendation_id) { +
  1. +
    + {{ reco.action }} + {{ + ruleLabel(reco.rule_reference) + }} +
    +

    {{ reco.explanation }}

    +
  2. + } +
+
+ } +
diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss new file mode 100644 index 0000000..9456376 --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss @@ -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); +} diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts new file mode 100644 index 0000000..1f3ae01 --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts @@ -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 { + 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 { + 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 }, + recosMock: { getRecommendations: ReturnType }, + inputs: Record = {}, +) { + 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) { + fixture.detectChanges(); + fixture.detectChanges(); +} + +function texte(fixture: ComponentFixture): 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); + }); +}); diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts new file mode 100644 index 0000000..05b3829 --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts @@ -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, +): RecommendedAlertView[] { + const parAlerte = new Map(); + 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(null); + alertId = input(null); + sites = input([]); + + private data = signal(null); + private reloadTick = signal(0); + loading = signal(true); + error = signal(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(() => { + 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 { + this.loading.set(false); + this.error.set(UNAVAILABLE_MESSAGE); + this.data.set(null); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.html b/apps/frontend/src/app/shared/components/ui/icon/icon.html new file mode 100644 index 0000000..869ccd1 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.html @@ -0,0 +1,37 @@ + + @switch (name()) { + @case ('spike') { + + + } + @case ('threshold') { + + + } + @case ('anomaly') { + + } + @case ('outage') { + + + } + @case ('sensor') { + + + + + + } + } + diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.scss b/apps/frontend/src/app/shared/components/ui/icon/icon.scss new file mode 100644 index 0000000..c90a9ce --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.scss @@ -0,0 +1,12 @@ +:host { + display: inline-flex; + flex-shrink: 0; + width: 1em; + height: 1em; + vertical-align: -0.125em; +} + +svg { + width: 100%; + height: 100%; +} diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.spec.ts b/apps/frontend/src/app/shared/components/ui/icon/icon.spec.ts new file mode 100644 index 0000000..ce3dedc --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.spec.ts @@ -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'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.ts b/apps/frontend/src/app/shared/components/ui/icon/icon.ts new file mode 100644 index 0000000..dc302e9 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.ts @@ -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(); + label = input(null); +} diff --git a/apps/frontend/src/app/shared/models/alert-presentation.spec.ts b/apps/frontend/src/app/shared/models/alert-presentation.spec.ts new file mode 100644 index 0000000..b31fdb8 --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert-presentation.spec.ts @@ -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'); + }); +}); diff --git a/apps/frontend/src/app/shared/models/alert-presentation.ts b/apps/frontend/src/app/shared/models/alert-presentation.ts new file mode 100644 index 0000000..cffa772 --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert-presentation.ts @@ -0,0 +1,41 @@ +import { BadgeTone } from '../components/ui/badge/badge'; +import { AlertMetric, AlertSeverity, AlertType } from './alert.model'; + +// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se +// lisait comme « tout va bien » à côté des rouges. +export const TON_PAR_SEVERITE: Record = { + low: 'neutral', + medium: 'warning', + high: 'danger', + critical: 'critical', +}; + +export const LIBELLE_PAR_SEVERITE: Record = { + low: 'Faible', + medium: 'Moyenne', + high: 'Élevée', + critical: 'Critique', +}; + +export const LIBELLE_PAR_TYPE: Record = { + spike: 'Pic de consommation', + threshold: 'Seuil dépassé', + anomaly: 'Anomalie', + outage: 'Coupure', + sensor: 'Capteur', +}; + +export const UNITE_PAR_METRIQUE: Record = { + consumption_kw: 'kW', + consumption_kwh: 'kWh', +}; + +export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical']; + +export const TYPES_ALERTE: readonly AlertType[] = [ + 'spike', + 'threshold', + 'anomaly', + 'outage', + 'sensor', +]; diff --git a/apps/frontend/src/app/shared/models/alert.model.ts b/apps/frontend/src/app/shared/models/alert.model.ts index 028f35a..8a51c93 100644 --- a/apps/frontend/src/app/shared/models/alert.model.ts +++ b/apps/frontend/src/app/shared/models/alert.model.ts @@ -1,13 +1,16 @@ export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'; export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor'; +export type AlertMetric = 'consumption_kw' | 'consumption_kwh'; export interface Alert { - alert_id: string; - timestamp: string; + alert_id: number; site_id: string; - severity: AlertSeverity; + timestamp: string; type: AlertType; + severity: AlertSeverity; message: string; - value: number; - threshold: number; + value: number | null; + threshold: number | null; + metric: AlertMetric | null; + prediction_id: number | null; } diff --git a/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts b/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts new file mode 100644 index 0000000..63b8556 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts @@ -0,0 +1,23 @@ +import { libelleRegle, tonRegle } from './recommendation-presentation'; + +describe('recommendation-presentation', () => { + it('traduit les sept règles connues du moteur', () => { + expect(libelleRegle('spike-delestage-v1')).toBe('Délestage'); + expect(libelleRegle('threshold-reduction-v1')).toBe('Réduction de puissance'); + expect(libelleRegle('outage-secours-v1')).toBe('Alimentation de secours'); + expect(libelleRegle('sensor-maintenance-v1')).toBe('Maintenance capteur'); + expect(libelleRegle('anomaly-verification-v1')).toBe('Vérification'); + expect(libelleRegle('escalade-astreinte-v1')).toBe('Escalade astreinte'); + expect(libelleRegle('contrat-puissance-v1')).toBe('Contrat de puissance'); + }); + + it('affiche telle quelle une référence de règle inconnue', () => { + expect(libelleRegle('spike-delestage-v2')).toBe('spike-delestage-v2'); + }); + + it("réserve le ton critique à l'escalade vers l'astreinte", () => { + expect(tonRegle('escalade-astreinte-v1')).toBe('critical'); + expect(tonRegle('spike-delestage-v1')).toBe('neutral'); + expect(tonRegle('inconnue-v9')).toBe('neutral'); + }); +}); diff --git a/apps/frontend/src/app/shared/models/recommendation-presentation.ts b/apps/frontend/src/app/shared/models/recommendation-presentation.ts new file mode 100644 index 0000000..f52d294 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation-presentation.ts @@ -0,0 +1,23 @@ +import { BadgeTone } from '../components/ui/badge/badge'; + +// Contrainte : une règle dont le sens change reçoit un suffixe -v2 côté backend (ADR 0006) ; +// une référence inconnue s'affiche donc telle quelle plutôt que de casser la vue. +const LIBELLE_PAR_REGLE: Record = { + 'spike-delestage-v1': 'Délestage', + 'threshold-reduction-v1': 'Réduction de puissance', + 'outage-secours-v1': 'Alimentation de secours', + 'sensor-maintenance-v1': 'Maintenance capteur', + 'anomaly-verification-v1': 'Vérification', + 'escalade-astreinte-v1': 'Escalade astreinte', + 'contrat-puissance-v1': 'Contrat de puissance', +}; + +const REGLE_ESCALADE = 'escalade-astreinte-v1'; + +export function libelleRegle(reference: string): string { + return LIBELLE_PAR_REGLE[reference] ?? reference; +} + +export function tonRegle(reference: string): BadgeTone { + return reference === REGLE_ESCALADE ? 'critical' : 'neutral'; +} diff --git a/apps/frontend/src/app/shared/models/recommendation.model.ts b/apps/frontend/src/app/shared/models/recommendation.model.ts new file mode 100644 index 0000000..c017b72 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation.model.ts @@ -0,0 +1,14 @@ +export interface Recommendation { + recommendation_id: number; + alert_id: number; + action: string; + explanation: string; + rule_reference: string; + created_at: string; +} + +export interface RecommendationGenerationReport { + alerts_examined: number; + recommendations_created: number; + already_present: number; +} diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index 5599780..179d4ec 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -2,6 +2,7 @@ @use 'styles/forms'; @use 'styles/auth-page'; @use 'styles/links'; +@use 'styles/tables'; body { margin: 0; diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss index 9bbfb0c..7cde5d1 100644 --- a/apps/frontend/src/styles/_forms.scss +++ b/apps/frontend/src/styles/_forms.scss @@ -29,3 +29,18 @@ color: var(--color-disabled); margin-top: 0.25rem; } + +// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur +// reprend en dur la valeur de --color-text-muted. +.form-select { + @extend .form-input; + padding-right: 2.25rem; + color: var(--color-text); + background-color: var(--color-surface); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.6rem center; + background-size: 1rem; + appearance: none; + cursor: pointer; +} diff --git a/apps/frontend/src/styles/_tables.scss b/apps/frontend/src/styles/_tables.scss new file mode 100644 index 0000000..36b0026 --- /dev/null +++ b/apps/frontend/src/styles/_tables.scss @@ -0,0 +1,43 @@ +// Piège : `ev-card` pose son padding sur `:host`, compilé en `[_nghost-…]` (même spécificité +// qu'une classe) et injecté après la feuille globale ; il faut le sélecteur d'élément pour gagner. +ev-card.ev-table-card { + padding: 0; + overflow: hidden; +} + +.ev-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: var(--font-size-xs); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + tbody tr:last-child td { + border-bottom: none; + } + + tbody tr:hover td { + background: var(--color-bg); + } +} + +.ev-table__number { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.ev-table__muted { + color: var(--color-text-muted); +} diff --git a/apps/frontend/src/styles/_tokens.scss b/apps/frontend/src/styles/_tokens.scss index 2e7663b..dd44c0e 100644 --- a/apps/frontend/src/styles/_tokens.scss +++ b/apps/frontend/src/styles/_tokens.scss @@ -29,10 +29,17 @@ // Typo, rayons, ombre --font-family: 'Segoe UI', system-ui, sans-serif; + --font-size-xs: 0.75rem; + --font-size-sm: 0.85rem; + --font-size-md: 1rem; + --font-size-lg: 1.25rem; + --font-size-xl: 1.75rem; + --font-size-2xl: 2.25rem; --radius-sm: 8px; --radius-md: 12px; --radius-pill: 999px; --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06); + --shadow-card-hover: 0 6px 16px rgba(0, 0, 0, 0.08); // Espacements --space-1: 0.35rem; diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index d2fbb9c..888336f 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -3,6 +3,7 @@ { "compileOnSave": false, "compilerOptions": { + "strict": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, diff --git a/db/init/120-airflow-database.sql b/db/init/120-airflow-database.sql index 05b7f72..fb0d9b2 100644 --- a/db/init/120-airflow-database.sql +++ b/db/init/120-airflow-database.sql @@ -1,4 +1,4 @@ --- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base +-- Base de metadonnees Airflow (api-server + scheduler + dag-processor, LocalExecutor). Separee de la base -- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire -- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un -- service dedie, pour ne pas ajouter un conteneur de plus (issue #115). diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 868ae17..971b608 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -5,6 +5,8 @@ # moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy. # Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour # `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi. +# Pourquoi : ports du proxy et origine publique en variables, pour que deux environnements +# cohabitent sur la même machine, chacun dans son projet Compose (ADR 0009). name: enervision @@ -12,12 +14,15 @@ services: db: ports: !override - "127.0.0.1:${POSTGRES_PORT:-5433}:5432" + environment: + TS_TUNE_MEMORY: ${TS_TUNE_MEMORY:-2GB} + TS_TUNE_NUM_CPUS: ${TS_TUNE_NUM_CPUS:-2} mailpit: ports: !override - "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025" - airflow-webserver: + airflow-apiserver: ports: !override - "127.0.0.1:${AIRFLOW_PORT:-8080}:8080" @@ -37,22 +42,22 @@ services: APP_ENV: prod APP_DEBUG: "false" APP_TRUST_PROXY_HEADERS: "true" - APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local} - APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password + APP_CORS_ORIGINS: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}} + APP_FRONTEND_RESET_PASSWORD_URL: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}/reset-password frontend: ports: !reset null proxy: - image: nginx:1.28-alpine + image: nginx:1.31-alpine depends_on: backend: condition: service_healthy frontend: condition: service_started ports: - - "80:80" - - "443:443" + - "${PROXY_HTTP_PORT:-80}:80" + - "${PROXY_HTTPS_PORT:-443}:443" volumes: - ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro - ./infra/proxy/conf.d:/etc/nginx/conf.d:ro diff --git a/docker-compose.yml b/docker-compose.yml index da19f43..b6d9f5d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,9 +5,9 @@ name: enervision -# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du -# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte -# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce +# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais de +# l'api-server. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte +# que sur `airflow-scheduler` en pratique, mais reste partage avec l'api-server pour que ce # dernier puisse au besoin l'inspecter sans en devenir dependant. x-airflow-common: &airflow-common build: @@ -19,23 +19,33 @@ x-airflow-common: &airflow-common # Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de # filtrer les services : une variable requise manquante casserait aussi `make db-up`, # `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par - # `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent. + # `airflow-init` (ci-dessous), dont `api-server`, `dag-processor` et `scheduler` dependent. AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-} - AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-} + AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-} + # Signe les jetons entre scheduler, tâches et api-server. Conteneurs distincts : un secret + # généré au démarrage ne serait pas partagé, il doit venir du .env. + AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-} + AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/ + # FabAuthManager plutôt que le SimpleAuthManager par défaut d'Airflow 3 : seul le provider + # FAB sait créer le compte admin que `airflow-init` pose via `_AIRFLOW_WWW_USER_*`. + AIRFLOW__CORE__AUTH_MANAGER: airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow # Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) : # memes identifiants que le backend en attendant. ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db - # Le DAG `alertes` lance le backend en sous-processus : il lit `DATABASE_URL`, en - # dialecte asyncpg, là où le pipeline ML lit `ML_DATABASE_URL`. + # Les DAGs backend lisent `DATABASE_URL` en dialecte asyncpg, là où le pipeline ML + # utilise `ML_DATABASE_URL`. DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} - # Clé distincte de celle de l'API : la détection ne signe ni ne vérifie aucun jeton, et - # Airflow permet d'exécuter du code depuis son interface (cf. ADR 0008). + + # Clé distincte de celle de l'API : les traitements lancés par Airflow ne signent ni ne + # vérifient aucun jeton. Airflow permet d'exécuter du code depuis son interface (ADR 0008). APP_SECRET_KEY: ${AIRFLOW_APP_SECRET_KEY:-} + volumes: - ./etl/airflow/dags:/opt/airflow/dags - ./etl/airflow/plugins:/opt/airflow/plugins + - ./data/raw:/opt/data/raw:ro - airflow_logs:/opt/airflow/logs - airflow_ml_state:/opt/ml/state restart: unless-stopped @@ -110,11 +120,11 @@ services: # Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont # portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte # aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume - # `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son - # succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement, - # jamais par `argv` (ni `ps`, ni `docker compose config`). + # `pgdata` deja peuple) fait echouer ce service, et api-server, dag-processor et scheduler, + # qui attendent son succes, ne demarrent pas sur une base non migree. Le mot de passe passe + # par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`). # Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous - # refuse en plus les deux cles de chiffrement vides. + # refuse en plus les cles et secrets vides. airflow-init: <<: *airflow-common restart: "no" @@ -134,13 +144,14 @@ services: - | set -euo pipefail : "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}" - : "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}" + : "$${AIRFLOW__API__SECRET_KEY:?AIRFLOW_API_SECRET_KEY manquant dans .env}" + : "$${AIRFLOW__API_AUTH__JWT_SECRET:?AIRFLOW_JWT_SECRET manquant dans .env}" : "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}" exec airflow version - airflow-webserver: + airflow-apiserver: <<: *airflow-common - command: webserver + command: api-server ports: - "${AIRFLOW_PORT:-8080}:8080" depends_on: @@ -149,7 +160,7 @@ services: airflow-init: condition: service_completed_successfully healthcheck: - test: ["CMD", "curl", "--fail", "http://localhost:8080/health"] + test: ["CMD", "curl", "--fail", "http://localhost:8080/api/v2/monitor/health"] interval: 30s timeout: 10s retries: 5 @@ -158,6 +169,24 @@ services: airflow-scheduler: <<: *airflow-common command: scheduler + environment: + <<: *airflow-common-env + # LocalExecutor exécute les tâches dans le scheduler : lui seul a besoin des + # identifiants de l'API Mock. + APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr} + APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-} + APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-} + APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} + depends_on: + db: + condition: service_healthy + airflow-init: + condition: service_completed_successfully + + # Obligatoire depuis Airflow 3 : le scheduler ne parse plus les fichiers de dags/ lui-même. + airflow-dag-processor: + <<: *airflow-common + command: dag-processor depends_on: db: condition: service_healthy diff --git a/docs/ML-START.md b/docs/ML-START.md index 68518ac..07c1abc 100644 --- a/docs/ML-START.md +++ b/docs/ML-START.md @@ -25,7 +25,7 @@ Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas. |---|---|---| | `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée | | `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement | -| `load_recent_from_database(connection, since=…)` | `reading` joint à `site`, **borné par `since`** | Scoring | +| `load_recent_from_database(connection, since=…, until=…)` | `reading` joint à `site`, **borné des deux côtés** | Scoring | L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une @@ -90,14 +90,25 @@ consommation prévue de **l'heure suivant sa dernière lecture connue**, et écr ### Ce que le run écrit, et ce qu'il n'écrase pas La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run -insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra -possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui -n'existe pas encore. +insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rend +possible la comparaison prévision contre réalisé. La surveillance de dérive s'en sert : elle +retient, pour chaque `(site_id, target_at)`, la ligne du run le plus récent, celle-là même que +sert `GET /api/v1/predictions`. Voir l'[ADR 0013](adr/0013-surveillance-de-derive-dans-le-backend.md). Trois contraintes de cohérence sont portées par la base et non par le code applicatif : `status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ; `insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à -`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. +`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. Elles +sont vérifiées depuis le code qui écrit par `ml/tests/test_score_integration.py`, sur une vraie +base : un double ne prouverait rien d'une contrainte SQL. + +**`--now` borne la fenêtre des deux côtés.** `load_recent_from_database` exige un `until` autant +qu'un `since`, et le scoring lui passe l'instant de référence. Sans cette borne haute, +`build_scoring_frame` repartait de la dernière lecture de toute la table quelle que soit la valeur +demandée : `target_at` valait toujours « fin du jeu + 1 h », et l'âge de la dernière lecture +devenait négatif sans franchir le seuil de péremption. Rejouer le scoring sur des instants passés +produit désormais des prévisions dont le réalisé existe déjà, ce dont la surveillance de dérive a +besoin pour se démontrer sur un jeu figé. ### `model_reference` est un hachage, pas un nom de fichier @@ -142,6 +153,10 @@ flowchart LR train -- "models/*.txt + run MLflow" --> score score -- "INSERT" --> prediction prediction -- "lecture seule" --> route + prediction -- "prévu" --> derive["app.monitoring.drift
écart prévu / réalisé"] + reading -- "réalisé" --> derive + derive -- "INSERT" --> rapport[("drift_report")] + rapport -- "lecture seule" --> monitoring["GET /api/v1/monitoring/drift"] ``` **La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.** @@ -163,8 +178,12 @@ flowchart LR Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly` (issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme -l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la -surveillance de dérive, portée par les issues #44 et #45. +l'entraînement, dont le DAG `ml_train` n'a pas de planification. + +La surveillance de dérive traverse cette frontière **dans le sens de la table vers le backend**, +sans la percer : elle relit `prediction` et `reading` en SQL, ne charge aucun modèle, et n'appelle +pas MLflow. Son calcul, son seuil et son refus de comparer à la métrique d'entraînement sont dans +l'[ADR 0013](adr/0013-surveillance-de-derive-dans-le-backend.md). --- @@ -175,3 +194,4 @@ surveillance de dérive, portée par les issues #44 et #45. - [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions - [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions` - [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données +- [ADR 0013](adr/0013-surveillance-de-derive-dans-le-backend.md) : la surveillance de dérive diff --git a/docs/README.md b/docs/README.md index ca50a10..b36ffea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,3 +15,8 @@ | [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` | | [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose | | [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement | +| [0009](adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé | +| [0010](adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application | +| [0011](adr/0011-enervision-procedure-deploiement.md) | Procédure de déploiement, telle qu'exécutée le 22/09/2026 | +| [0012](adr/0012-enervision-deploiement-rec-prod-vm-eni.md) | État de la recette et de la production sur la VM ENI | +| [0013](adr/0013-surveillance-de-derive-dans-le-backend.md) | La surveillance de dérive vit dans le backend et écrit sa propre table | diff --git a/docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md b/docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md new file mode 100644 index 0000000..6b32e89 --- /dev/null +++ b/docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md @@ -0,0 +1,82 @@ +# 0009 - Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé + +- Statut : accepté +- Date : 2026-09-21 + +## Contexte + +La grille note EC03 à EC06 sur ce qui est déployé et fonctionnel au J10. Au 21/09, rien ne +l'est : la CI s'arrête au merge (issue #21), la topologie Compose avec reverse proxy +([ADR 0007](0007-terminaison-tls-et-reverse-proxy-nginx.md)) n'a jamais quitté le poste, et le +module Terraform k3s n'a jamais été appliqué. L'école met à disposition une seule VM, +`eadl-2025-nantes-g3`, sur une adresse privée que les runners hébergés par GitHub ne joignent +pas, sans DNS public. + +Il faut deux environnements, recette et production, parce que la stratégie de branches en a +déjà deux, `dev` et `main`, et qu'un déploiement direct en production à chaque merge sur `dev` +n'est pas défendable. + +La branche `feat/deploy` tentait de déployer par provisioners Terraform : nginx système et copie +du build Angular. La revue postée sur #21 relève huit points bloquants, dont des racines `rec` +et `prod` qui ne passent pas `terraform validate`. + +## Décision + +**Un projet Docker Compose par environnement, sur la même machine.** Deux clones du dépôt, +`/srv/enervision/rec` sur `dev` et `/srv/enervision/prod` sur `main`, chacun avec son `.env` et +son `COMPOSE_PROJECT_NAME`. Le nom de projet préfixe volumes, réseau et conteneurs : les deux +stacks ne partagent rien. + +**Les ports du proxy et l'origine publique deviennent des variables** de +`docker-compose.prod.yml`. La production garde 80 et 443. La recette publie 8443 et ramène sa +redirection HTTP sur la boucle locale, faute de quoi elle renverrait vers la production. Base, +Mailpit et Airflow restent sur `127.0.0.1`, décalés d'un port. + +**Deux noms d'hôte**, `enervision.local` et `rec.enervision.local`, sur la même IP. Le cookie de +rafraîchissement est posé par hôte, pas par port : un seul nom ferait se déconnecter la +production à chaque connexion en recette. + +**Un runner GitHub Actions auto-hébergé sur la VM** exécute `deploy.yml` : un `push` sur `dev` +déploie la recette, un `push` sur `main` déploie la production après approbation dans +l'environnement GitHub `prod`. Le job aligne le clone sur la branche puis lance `make stack-up`. +Les images sont construites sur la machine. + +**Les secrets vivent dans le `.env` de chaque dossier**, générés sur la machine par +`scripts/provision-host.sh`, jamais dans git ni dans GitHub. Le runner n'a besoin d'aucun +secret. + +## Alternatives écartées + +- **k3s avec un namespace par environnement** : le cluster serait vide, sans manifeste, sans + registre, sans stockage persistant. C'est la cible de `10-infra.md`, pas celle de la semaine. +- **Provisioners Terraform de `feat/deploy`** : voir la revue sur #21. Terraform reste l'outil + de provisionnement de la machine, pas de livraison applicative. +- **Deux machines**, VM Proxmox et VM Azure ENI : une deuxième infrastructure à justifier devant + le jury et à provisionner, pour un bénéfice nul sur la grille. +- **Un seul proxy frontal routant par nom d'hôte vers les deux stacks** : des URL sans port, + mais le proxy devrait joindre deux réseaux Compose où les services portent les mêmes noms. + La complexité dépasse le gain. +- **Images publiées sur GHCR et déployées par digest** : la bonne pratique, remise à plus tard. + Un registre à authentifier sur la machine, alors que le runner y construit déjà. + +## Conséquences + +- Deux TimescaleDB sur une machine de 8 Go : sans réglage, chacune se réserverait 25 % de la + RAM au premier démarrage. L'overlay fixe `TS_TUNE_MEMORY` à 2 Go et `TS_TUNE_NUM_CPUS` à 2 par + base. Airflow 3 n'a rien à régler de ce côté : son api-server lance un seul worker par défaut, + là où le webserver d'Airflow 2 en lançait quatre. La montée à 32 Go prévue par les consignes + est à demander. +- Un runner auto-hébergé sur un dépôt public exécute le code qu'on lui envoie. `deploy.yml` ne + se déclenche jamais sur `pull_request`, le runner tourne sous un utilisateur dédié, et le + dépôt doit exiger une approbation pour les workflows des PR externes. +- Les deux environnements construisent leurs images séparément à partir du même commit : ce qui + tourne en production a été construit deux fois, pas promu. Le passage à GHCR lèvera cette + limite. +- Le `make stack-up` du runner reconstruit l'image Airflow, qui copie `ml/` et `apps/backend/`, + à chaque push : plusieurs minutes par déploiement, acceptable pour la cadence du projet. +- `environments/prod` de Terraform reste vide. Le provisionnement de la machine est porté par + `scripts/provision-host.sh`, que Terraform pourra appeler par `remote-exec` le jour où une + racine visant la VM existera. Cette racine existe depuis l'[ADR 0010](0010-terraform-provisionne-github-actions-deploie.md), + sous le nom `environments/vm-eni`, et `environments/prod` a disparu avec elle. +- L'image frontend quitte `dhi.io/nginx`, registre authentifié dont personne n'a l'accès, pour + `nginx:1.28-alpine`, la même image que le proxy. Elle n'avait jamais été construite. diff --git a/docs/adr/0010-terraform-provisionne-github-actions-deploie.md b/docs/adr/0010-terraform-provisionne-github-actions-deploie.md new file mode 100644 index 0000000..c854866 --- /dev/null +++ b/docs/adr/0010-terraform-provisionne-github-actions-deploie.md @@ -0,0 +1,77 @@ +# 0010 - Terraform provisionne la machine, GitHub Actions déploie l'application + +- Statut : accepté +- Date : 2026-09-22 + +## Contexte + +L'[ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md) a posé la livraison : deux +projets Compose sur la VM ENI, alignés sur `dev` et sur `main` par un runner auto-hébergé. Elle +ne dit pas qui prépare la machine. C'est `scripts/provision-host.sh`, lancé à la main en SSH. + +La grille d'évaluation attend en C22 que l'infrastructure soit « provisionnée via du code +(Terraform, Ansible…) ». Le seul Terraform du dépôt installe un cluster k3s que rien ne +consomme, qui n'a jamais été appliqué, et dont la racine ne passait même pas `terraform init` +depuis que Terraform refuse les provisioners `destroy` dont la connexion lit autre chose que +`self`. Sa racine s'appelait `environments/dev`, nom qui laissait croire à un environnement +applicatif alors que les deux environnements réels sont `rec` et `prod`, sur la même machine. + +La branche `feat/deploy` (PR #141) proposait la réponse inverse : Terraform construit les +images, lance les conteneurs et copie les sources par SSH. La revue a relevé deux racines sur +trois qui ne passent pas `terraform validate`, le mot de passe SSH écrit en clair dans le state, +un backend lancé sans base ni variables d'environnement, et trois architectures différentes pour +trois environnements. + +## Décision + +**Terraform provisionne la machine, GitHub Actions déploie l'application.** La frontière est +nette et vérifiable : `infra/terraform/environments/vm-eni` installe Docker et le plugin +Compose, exécute `scripts/provision-host.sh`, enregistre le runner. Il ne construit aucune +image, ne lance aucun conteneur, et un `apply` n'interrompt pas la stack qui tourne. + +**Le déploiement continu ne change pas.** `deploy.yml` reste le seul chemin de livraison : push +sur `dev` ou `main`, alignement du clone, `make stack-up`, sonde `/api/v1/health/ready`. + +**Le Bash reste la mécanique, Terraform devient le point d'entrée.** `provision-host.sh` connaît +les deux environnements, leurs ports décalés, leurs secrets et leurs certificats. Le réécrire en +HCL créerait une seconde source de vérité qui divergerait au premier changement de port. + +**Aucun secret dans le state.** Authentification SSH par clé seulement, pas de variable de mot +de passe. Le jeton d'enregistrement du runner est une variable `sensitive` fournie à l'`apply`, +jamais un `trigger` : les `triggers` sont la seule partie d'un `null_resource` que Terraform +persiste. + +**Les racines portent le nom de ce qu'elles provisionnent**, pas d'un environnement applicatif : +`vm-eni` pour la machine, `k3s-cible` pour le cluster resté en cible. `environments/prod`, +dossier vide, disparaît. + +## Alternatives écartées + +- **Ansible à la place du Bash** : plus idiomatique pour de la configuration de machine, et le + jury le reconnaîtrait immédiatement comme de l'IaC. Mais c'est un outil de plus à installer et + à faire tourner, pour réécrire un script qui fonctionne, à trois jours du gel technique. +- **Provisioners applicatifs de `feat/deploy`** : voir la revue sur #141. Terraform y devenait un + orchestrateur concurrent de Compose, sans base de données ni migrations. +- **Terraform appelle aussi `make stack-up`** : le premier démarrage serait plus court d'une + commande, mais Terraform se mettrait à porter la livraison, que le runner rejoue à chaque + push. Deux chemins pour le même acte, c'est précisément ce que #141 montre qu'il ne faut pas. +- **k3s tout de suite** : le cluster serait vide, sans manifeste, sans registre et sans stockage + persistant. Le module reste, documenté comme cible. +- **State Terraform distant** : un seul opérateur, pas d'exécution concurrente. Le backend local + suffit, comme pour `k3s-cible`. + +## Conséquences + +- Le premier `apply` exige un jeton d'enregistrement du runner, valable une heure et pour une + seule inscription, que seul un administrateur du dépôt peut créer. L'`apply` n'est donc pas + rejouable sans intervention humaine, ce qui est acceptable : il ne se joue qu'à l'installation. +- L'utilisateur propriétaire de `/srv/enervision` doit exister sur la machine avant l'`apply`. + Terraform vérifie et échoue tôt plutôt que de le créer : décider d'un compte système est une + décision d'administration, pas un effet de bord de déploiement. +- Terraform ne sait rien de l'état de la stack. `terraform plan` ne dira jamais que la recette + est tombée ; c'est la sonde de `deploy.yml` qui le dit. +- Pas de provisioner `destroy` sur le runner : il imposerait de mettre le chemin de la clé SSH + dans le state, et `svc.sh uninstall` ne désinscrit pas le runner côté GitHub. Le retrait reste + manuel, depuis les paramètres du dépôt. +- C22 cesse de reposer sur `docker-compose.prod.yml` seul. C23 reste porté par les scripts, que + Terraform appelle désormais au lieu de les remplacer. diff --git a/docs/adr/0011-enervision-procedure-deploiement.md b/docs/adr/0011-enervision-procedure-deploiement.md new file mode 100644 index 0000000..fb74241 --- /dev/null +++ b/docs/adr/0011-enervision-procedure-deploiement.md @@ -0,0 +1,162 @@ +# EnerVision · procédure de déploiement (22/09/2026) + +Terraform provisionne la machine, GitHub Actions déploie (ADR 0010). Deux environnements Compose +sur la VM ENI `10.101.200.37` : `rec` sur la branche `dev`, `prod` sur `main` (ADR 0009). + +| | recette | production | +|---|---|---| +| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` | +| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` | +| URL | `https://rec.enervision.local:8443` | `https://enervision.local` | +| Proxy HTTP / HTTPS | `127.0.0.1:8081` / `8443` | `80` / `443` | +| Postgres / Mailpit / Airflow (locaux) | `5434` / `8026` / `8082` | `5433` / `8025` / `8080` | + +## 0. Avant toute commande + +1. **Clé SSH déposée** sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@10.101.200.37`. + Terraform ne gère **pas** l'authentification par mot de passe (elle finirait dans le state). +2. **L'utilisateur propriétaire existe déjà** sur la VM (ex. `enervision`) : il possède + `/srv/enervision` et fait tourner le runner. Terraform échoue tôt s'il manque, il ne le crée pas. +3. **Jeton d'enregistrement du runner** : Settings > Actions > Runners > New self-hosted runner. + Valable 1 h, une seule inscription, créé par un administrateur du dépôt (ineszang). +4. **`main` est en retard de 64 commits** et ne porte ni `deploy.yml`, ni `provision-host.sh`, ni + le Terraform, ni l'overlay paramétré (ports et `PUBLIC_ORIGIN` en dur). Tant que `dev` n'est pas + remonté dans `main`, seule la recette est déployable : le clone `prod` sera préparé mais son + `make stack-up` publierait 80/443 sans les variables, et aucun push sur `main` ne déclencherait + de déploiement (le workflow n'y existe pas). **Remonter `dev` → `main` avant de toucher à prod.** + +## 1. Provisionner la machine (depuis le poste) + +```bash +cd infra/terraform/environments/vm-eni +cp terraform.tfvars.example terraform.tfvars +terraform init +terraform apply +``` + +`terraform.tfvars`, ignoré par git, trois valeurs à renseigner : + +```hcl +proprietaire = "enervision" # doit exister sur la VM +runner_version = "2.330.0" # épingler depuis github.com/actions/runner/releases +runner_token = "..." # jeton d'1 h, à retirer du fichier après l'apply +``` + +Défauts utiles : `ssh_host = "10.101.200.37"`, `ssh_user = "root"`, +`ssh_private_key_path = "~/.ssh/id_ed25519"`, `racine = "/srv/enervision"`, +`runner_labels = "eni-g3"` (ciblé par `deploy.yml`), `runner_dossier = "/opt/actions-runner"`. + +L'apply fait trois choses, dans cet ordre : Docker + plugin Compose et `usermod -aG docker`, +puis `scripts/provision-host.sh`, puis l'installation et l'enregistrement du runner en service. +Il ne construit aucune image et ne démarre aucun conteneur : un apply n'interrompt pas la stack. + +Rejouable : un clone existant est réaligné, un `.env` présent n'est **jamais** réécrit, un +certificat présent n'est jamais régénéré. Un nouvel apply de la ressource runner redemande un +jeton frais (il expire en 1 h). + +## 2. Variables d'environnement + +Un `.env` par dossier, en `600`, généré sur la machine depuis `.env.example`. **Aucun secret ne +passe par git ni par GitHub** : le runner n'en reçoit aucun (seul `SONAR_TOKEN` existe côté CI). + +**Générés automatiquement** : `POSTGRES_PASSWORD`, `APP_SECRET_KEY`, `AIRFLOW_FERNET_KEY`, +`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_ADMIN_PASSWORD`, `AIRFLOW_APP_SECRET_KEY`. + +**Fixés par environnement** : `COMPOSE_PROJECT_NAME`, `PUBLIC_HOST`, `PUBLIC_ORIGIN`, +`PROXY_HTTP_PORT`, `PROXY_HTTPS_PORT`, `POSTGRES_PORT`, `MAILPIT_UI_PORT`, `AIRFLOW_PORT`. + +**À renseigner à la main**, dans chaque `.env`, avant le premier démarrage : + +``` +APP_MOCK_API_USERNAME=... +APP_MOCK_API_PASSWORD=... +``` + +Garde-fou : le script refuse d'écrire un `.env` s'il reste un `change_me` hors `APP_MOCK_API_*` +(cas vécu d'une clé renommée en amont, `AIRFLOW_WEBSERVER_SECRET_KEY` sous Airflow 3). + +`APP_ENV=prod` et `APP_DEBUG=false` sont en dur dans l'overlay, pas dans le `.env` : la valeur +`local` du poste reprendrait le dessus et rouvrirait `/docs` sans cookie `__Secure-`. + +`TS_TUNE_MEMORY=2GB` et `TS_TUNE_NUM_CPUS=2` sont obligatoires : deux TimescaleDB sur 8 Go se +réserveraient 25 % de la RAM chacune. La montée à 32 Go est à demander. + +Certificats auto-signés générés par le script (`infra/proxy/tls/`), couvrant le nom d'hôte, +`localhost` et l'IP. Let's Encrypt (`make tls-acme`, `ACME_EMAIL`) reste hors d'atteinte sans +domaine public résolvable. + +## 3. Premier démarrage (manuel, une seule fois, sur la VM) + +```bash +cd /srv/enervision/rec && make stack-up # build + up + alembic upgrade head +cd /srv/enervision/prod && make stack-up # seulement après la remontée dev → main +``` + +`stack-up` refuse de démarrer si le certificat manque ou ne couvre pas `PUBLIC_HOST`, et applique +les migrations : sans elles la stack démarrerait verte sur une base sans schéma. + +Premier administrateur, stack démarrée, dans chaque dossier : + +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \ + python -m app.cli create-admin --email +``` + +Données historiques : `data/raw` n'est pas dans git. Déposer les fichiers dans chaque dossier +avant de déclencher le DAG `historical_import`. + +## 4. Réglages GitHub (administrateur du dépôt) + +- Environnement `prod` : branche `main` seule autorisée, **approbation d'un relecteur** requise. +- Environnement `rec` : branche `dev` seule autorisée, sans approbation. +- Settings > Actions : **« Require approval for all outside collaborators »**. Un runner + auto-hébergé sur un dépôt public exécute ce qu'on lui envoie ; `deploy.yml` ne se déclenche + jamais sur `pull_request`, et le runner ne tourne jamais en root. + +## 5. Déploiement continu, ensuite + +Un push sur `dev` déploie la recette, un push sur `main` la production après approbation. +Le job (runner `eni-g3`) aligne le clone (`fetch`, `checkout`, `reset --hard`), lance +`make stack-up`, puis sonde `/api/v1/health/ready` derrière le proxy pendant 3 minutes ; en cas +d'échec il publie `ps` et les 50 dernières lignes de `backend` et `proxy`. Pas de `checkout` dans +l'espace du runner : `.env`, certificats et volumes doivent survivre d'un déploiement à l'autre. +Concurrence par branche, sans annulation. + +Déclenchement manuel possible : `workflow_dispatch`. + +## 6. Vérifier + +```bash +curl -k https://localhost:8443/api/v1/health/ready # recette, sur la VM +curl -k https://localhost/api/v1/health/ready # production, sur la VM +``` + +Depuis un poste, ajouter à `/etc/hosts` : + +``` +10.101.200.37 enervision.local rec.enervision.local +``` + +Les deux noms sont obligatoires : le cookie `__Secure-ev_refresh` est posé par hôte et non par +port ; un seul nom déconnecterait la production à chaque connexion en recette. + +## Pièges à connaître + +- Compose **2.24.4 minimum** : l'overlay emploie `!override` et `!reset`, sans quoi l'API resterait + joignable en clair à côté du proxy. Le script le vérifie. +- Le runner doit tourner sous le propriétaire de `/srv/enervision` : sinon git refuse les clones + (propriété douteuse) et le `.env` en `600` lui échappe. Correctif : + `PROPRIETAIRE= bash scripts/provision-host.sh`. +- Chaque environnement reconstruit ses images à partir du même commit : la production n'exécute + pas l'artefact validé en recette, mais un second build. Le passage à GHCR lèvera cette limite. +- Un `.env` perdu se régénère, mais invalide les sessions et les connexions chiffrées par Airflow : + ils ne sont sauvegardés nulle part ailleurs. +- Retirer le runner se fait à la main, depuis les paramètres du dépôt : `terraform destroy` ne le + désinscrit pas. + +## Références dans le dépôt + +`docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md`, +`docs/adr/0010-terraform-provisionne-github-actions-deploie.md`, +`docs/architecture/50-cicd.md`, `docs/architecture/10-infra.md`, `infra/README.md`, +`scripts/provision-host.sh`, `.github/workflows/deploy.yml`, `docker-compose.prod.yml`. diff --git a/docs/adr/0012-enervision-deploiement-rec-prod-vm-eni.md b/docs/adr/0012-enervision-deploiement-rec-prod-vm-eni.md new file mode 100644 index 0000000..8d01d39 --- /dev/null +++ b/docs/adr/0012-enervision-deploiement-rec-prod-vm-eni.md @@ -0,0 +1,120 @@ +# EnerVision · Recette et production sur la VM ENI, aujourd'hui + +État au lundi 21 septembre 2026, 15h. Cible : deux environnements qui tournent sur la VM +`eadl-2025-nantes-g3` (`10.101.200.37`) avant vendredi 25/09 9h, déployés automatiquement depuis +GitHub. Ce document donne la solution retenue, ce qu'elle change dans le dépôt, et le déroulé de +l'après-midi avec qui fait quoi. + +## 1. La décision en une phrase + +**Deux projets Docker Compose sur la même VM, un par environnement, déployés par un runner GitHub +Actions installé sur la VM.** `dev` alimente la recette, `main` alimente la production. Terraform +reste ce qu'il est : le module k3s, cible à terme, non utilisé pour cette mise en ligne. + +| | Recette (`rec`) | Production (`prod`) | +|---|---|---| +| Branche | `dev` | `main` | +| Environnement GitHub | `rec` (créé ce midi) | `prod` (créé ce midi) | +| Dossier sur la VM | `/srv/enervision/rec` | `/srv/enervision/prod` | +| Projet Compose | `enervision-rec` | `enervision-prod` | +| URL | `https://rec.enervision.local:8443` | `https://enervision.local` | +| Proxy HTTPS | `8443` | `443` | +| Proxy HTTP (redirection) | `127.0.0.1:8081`, inutilisé | `80` | +| PostgreSQL, Mailpit, Airflow | `127.0.0.1` : `5434`, `8026`, `8082` | `127.0.0.1` : `5433`, `8025`, `8080` | +| Certificat | auto-signé, SAN `rec.enervision.local` | auto-signé, SAN `enervision.local` | +| Déclenchement | chaque push sur `dev` | push sur `main`, après approbation dans GitHub | + +Les deux noms d'hôte pointent sur la même IP. Deux lignes dans le `/etc/hosts` des postes de +l'équipe suffisent. Deux noms distincts sont indispensables : le cookie de rafraîchissement +`__Secure-ev_refresh` est posé par hôte, pas par port, et un seul nom ferait se déconnecter la +prod à chaque connexion sur la recette. + +## 2. Pourquoi c'est la solution la plus simple + +- **Tout existe déjà.** L'overlay `docker-compose.prod.yml`, le proxy Nginx TLS, les scripts de + certificat et `make stack-up` sont écrits et validés sur poste (PR #117, ADR 0007). Il ne + manque que quatre variables pour que deux instances cohabitent sur une machine. +- **Un projet Compose isole tout.** Volumes, réseau, noms de conteneurs sont préfixés par le nom + du projet. Casser la recette ne touche pas la prod, ce qui est la raison d'être d'une recette. +- **Le runner sur la VM est la seule façon d'atteindre une IP privée d'école depuis GitHub.** Les + runners hébergés par GitHub ne voient pas `10.101.200.37`. Le runner se connecte en sortie + vers GitHub, aucun port entrant n'est nécessaire. C'était le choix 16 du dossier EC01 : il + redevient tenu. +- **La promotion existe déjà dans la stratégie de branches** : `dev` puis `main` par PR. Le + même code est déployé en recette, puis en production, sans troisième mécanisme. + +Ce qu'on écarte, et pourquoi : + +| Piste | Pourquoi pas cette semaine | +|---|---| +| k3s avec deux namespaces | Le cluster serait vide : aucun manifeste, aucun registre d'images, aucun stockage persistant. Trois jours de travail sans valeur visible au J10 | +| Terraform de `feat/deploy` (nginx système + copie de fichiers) | Revue postée sur l'issue #21 : huit points bloquants, `rec` et `prod` ne passent pas `terraform validate`. On abandonne cette voie | +| Azure ENI pour la prod | Deuxième infrastructure à provisionner, choix à justifier devant le jury (document 03), et rien n'est prêt côté Azure | +| Images publiées sur GHCR | Meilleure pratique, mais un registre de plus à authentifier sur la VM. Les images se construisent sur la VM, où le runner tourne déjà. À faire ensuite, issue à ouvrir | +| Let's Encrypt | Aucun domaine public ne résout vers la VM. Auto-signé assumé, chemin ACME déjà câblé | + +## 3. Ce qui change dans le dépôt (une PR vers `dev`) + +| Fichier | Changement | Raison | +|---|---|---| +| `apps/frontend/Dockerfile` | `FROM nginx:1.28-alpine` à la place de `dhi.io/nginx:...` | Le registre Docker Hardened Images demande une authentification. L'image frontend n'a jamais été construite, sur aucun poste : c'est le premier point où `make stack-up` échouerait sur la VM | +| `docker-compose.prod.yml` | Ports du proxy en variables `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. Origine publique `PUBLIC_ORIGIN` pour CORS et le lien de réinitialisation. `TS_TUNE_MEMORY` sur la base | Deux proxys ne peuvent pas publier 80 et 443. L'origine de la recette porte un port. Deux TimescaleDB sur 8 Go se réserveraient chacune 2 Go sans réglage | +| `.env.example` | `COMPOSE_PROJECT_NAME`, les variables ci-dessus, ports de la recette en commentaire | Le `.env` de chaque dossier est la seule différence entre les deux environnements | +| `.github/workflows/deploy.yml` | Nouveau. `on: push` sur `dev` et `main`, `runs-on: [self-hosted, eni-g3]`, `environment: rec` ou `prod`, puis `git reset --hard origin/` et `make stack-up` dans le dossier de l'environnement | Le D de CI/CD, issue #21 | +| `scripts/provision-host.sh` | Nouveau. Vérifie Docker et Compose 2.24.4 ou plus, crée `/srv/enervision/{rec,prod}`, clone les deux branches | Rejouable, et réutilisable par Terraform plus tard | +| `docs/adr/0009-...md`, `10-infra.md`, `50-cicd.md`, `infra/proxy/README.md` | Décision, vue infra, vue CI/CD, tableau des ports | Règle du dépôt : la vue change dans la même PR que le composant | + +Ce qui ne change pas : `docker-compose.yml`, la configuration Nginx, `infra/terraform`. + +## 4. Déroulé de l'après-midi + +| # | Qui | Quoi | Durée | +|---|---|---|---| +| 1 | **ineszang** (seule admin du dépôt) | Environnement `prod` : branche autorisée `main`, un relecteur requis. Environnement `rec` : branche `dev`. Settings > Actions : « Require approval for all outside collaborators ». Générer le jeton d'enregistrement du runner (Settings > Actions > Runners > New self-hosted runner, Linux x64) et le transmettre à Johan | 10 min | +| 2 | **Johan** | Déposer sa clé sur la VM : `ssh-copy-id -i ~/.ssh/id_ed25519.pub root@10.101.200.37`, mot de passe du compte administrateur local des postes de l'école | 2 min | +| 3 | Johan + Claude | **Fait à 15h** : branche locale `feat/deploy-rec-prod` avec tous les changements du §3, image frontend reconstruite avec succès, fusion Compose vérifiée pour les deux environnements. Reste : commit, push, PR vers `dev` | fait | +| 4 | Claude, par SSH | `scripts/provision-host.sh` sur la VM. Écrire les deux `.env` (secrets générés sur la VM, jamais dans git). Certificats : `PUBLIC_HOST=rec.enervision.local PUBLIC_IP=10.101.200.37 make tls-selfsigned` dans `rec`, idem avec `enervision.local` dans `prod`. Puis `make stack-up` dans chaque dossier | 20 min plus la construction des images | +| 5 | Johan, sur la VM | Installer le runner sous un utilisateur non-root membre du groupe `docker`, label `eni-g3`, en service systemd (`./config.sh --unattended --labels eni-g3`, `sudo ./svc.sh install && sudo ./svc.sh start`) | 10 min | +| 6 | Équipe | Merger la PR dans `dev` : la recette se redéploie seule. Ouvrir la PR `dev` vers `main` : la prod se déploie après approbation dans l'onglet Environments | 15 min | +| 7 | Tous | Vérifier depuis un poste de l'équipe, `/etc/hosts` renseigné : connexion, tableau de bord, Airflow par tunnel SSH | 15 min | + +Contrôle en fin de chaîne, depuis la VM : + +```bash +curl -k https://localhost/api/v1/health/ready # prod +curl -k https://localhost:8443/api/v1/health/ready # rec +docker compose -p enervision-prod ps +docker compose -p enervision-rec ps +``` + +## 5. Ce qui peut faire échouer la journée, et la parade + +| Risque | Parade | +|---|---| +| **8 Go de RAM pour deux stacks complètes** (deux Airflow, deux TimescaleDB, deux API) | Demander dès maintenant le passage à 32 Go, prévu par les consignes. En attendant : `TS_TUNE_MEMORY=2GB` et deux workers gunicorn pour Airflow. Si la RAM ne suit pas, démarrer la recette sans Airflow (`docker compose up -d --scale airflow-webserver=0 --scale airflow-scheduler=0`) | +| **Compose trop ancien sur la VM** (les marqueurs `!override` et `!reset` exigent 2.24.4) | `docker compose version` en premier. Sinon installer le paquet `docker-compose-plugin` depuis le dépôt Docker | +| **Pas de sortie Internet depuis la VM** | `curl -sI https://github.com` et `docker pull hello-world` avant tout. Sans sortie, ni construction d'image ni runner : déploiement manuel par `scp` d'images, plan B lourd | +| **Runner auto-hébergé sur un dépôt public** | Le workflow de déploiement ne s'exécute que sur `push` vers `dev` et `main`, jamais sur `pull_request`. Réglage d'approbation des PR externes (étape 1). Runner sous un utilisateur dédié, jamais root | +| **Premier démarrage avec un volume `pgdata` vide** | C'est le cas nominal sur la VM : `db/init` crée les bases `enervision`, `enervision_test` et `airflow`. Ne pas restaurer un volume de poste | +| **Le jury accepte mal un certificat auto-signé** | Dire pourquoi avant qu'on le demande : aucun DNS public, ACME câblé et documenté, ADR 0007. Un clic « continuer » dans le navigateur | +| **Conflit avec `feat/deploy`** (ineszang y a mergé `dev` à 14h06) | Partager ce document avant de pousser. La PR remplace `feat/deploy`, elle ne s'y ajoute pas | + +## 6. Ce que ça donne pour la grille + +- **EC03, CI/CD** : la chaîne ne s'arrête plus au merge. Deux environnements, déploiement + automatique en recette, promotion approuvée en production, journal des déploiements dans + l'onglet Environments de GitHub. +- **EC04, cloud et sécurisation** : une application déployée et fonctionnelle, une seule surface + exposée par environnement, secrets hors de git et hors de GitHub, base et Airflow joignables + uniquement par tunnel SSH. +- **Dossier EC01** : le choix 16 (runner auto-hébergé, déploiement automatique) passe de « non + fait » à « tenu ». Le choix 12 (Ansible) reste non fait, et la réponse est prête : le + durcissement de la machine n'est pas automatisé, le script de provisionnement en est la + première brique, Terraform pourra l'appeler. + +## 7. Après vendredi, si on continue + +Dans l'ordre de valeur : images construites une fois en CI et publiées sur GHCR, puis déployées +par digest (vraie promotion d'artefact). Racine Terraform `environments/eni-g3` qui provisionne +la machine et le runner à partir du script. Sauvegarde de `pgdata` par `pg_dump` planifié. +Monitoring (issue #26). Et seulement ensuite la bascule k3s, si elle garde un sens. diff --git a/docs/adr/0013-surveillance-de-derive-dans-le-backend.md b/docs/adr/0013-surveillance-de-derive-dans-le-backend.md new file mode 100644 index 0000000..aec61b0 --- /dev/null +++ b/docs/adr/0013-surveillance-de-derive-dans-le-backend.md @@ -0,0 +1,119 @@ +# 0013 - La surveillance de dérive vit dans le backend et écrit sa propre table + +- Statut : accepté +- Date : 2026-09-22 + +## Contexte + +L'issue #45 demande des tests d'intégration API ↔ DB ↔ ML. Trois documents du dépôt annoncent +par ailleurs, depuis le jalon J3, une surveillance de dérive qui n'existe nulle part : +`docs/architecture/00-vue-ensemble.md` (« Surveillance de dérive (EC06, #44/#45) pas encore +construite »), `docs/ML-START.md` (« la dette qui subsiste est la surveillance de dérive »), et +le docstring de `write_predictions()` dans `ml/enervision_ml/score.py`, qui justifie l'absence +d'unicité sur `(site_id, target_at)` par la comparaison future entre prévu et réalisé. + +La matière première est en base : `prediction` porte ce que le modèle a annoncé, `reading` ce +qui est réellement arrivé. Restaient trois questions : où vit le calcul, à quoi on compare, et +où atterrit le résultat. + +## Décision + +**Le calcul vit dans `apps/backend`** : `repositories/drift.py` pour le SQL, `services/drift.py` +pour la logique, `monitoring/drift.py` pour la CLI, `api/v1/endpoints/monitoring.py` pour la +lecture. Le dossier `ml/` ne gagne pas une ligne. + +**Le résultat est persisté** dans une table `drift_report`, une ligne par site plus une ligne +globale que `site_id` à NULL désigne. + +**La comparaison oppose deux fenêtres vives de 168 h**, la récente et celle qui la précède, et +le verdict a trois valeurs : `stable`, `derive`, `indetermine`. + +### Pourquoi le backend, alors que le sujet est le modèle + +- **`prediction` n'est pas dans le périmètre de `ML_DATABASE_URL`.** `enervision_ml/config.py`, + `docs/ML-START.md` et l'[ADR 0003](0003-autorisation-rbac-a-trois-roles.md) désignent pour + cette variable un rôle PostgreSQL restreint **en lecture sur `reading` et `site`**. Mettre la + dérive dans `ml/` obligerait à élargir ce rôle à `prediction`, et à l'écriture : ce serait + contredire par le code la dette de moindre privilège que ces trois documents ont posée par + écrit. +- **L'alignement prévu contre réalisé existe déjà ici, une fois.** `AlertService._detect_anomaly` + croise `reading` et `prediction` sur le même instant, et `PredictionRepository.list_since` + porte déjà le piège des runs empilés. Le réécrire en SQL brut dans `ml/` créerait une seconde + source de vérité sur « quelle prédiction correspond à quelle lecture », ce que + l'[ADR 0006](0006-moteur-de-regles-dans-le-backend.md) a déjà refusé pour les règles. +- **La frontière de `docs/ML-START.md` tient.** FastAPI ne fait toujours pas tourner LightGBM : + la dérive lit deux tables et compare des nombres, elle n'évalue aucun modèle. + +**Conséquence assumée** : `enervision_ml.metrics.regression_metrics` n'est pas réutilisable, le +backend n'important pas `enervision_ml`. MAE, MAPE et biais sont donc réécrits, une quinzaine de +lignes. Cette duplication n'est pas celle que `build_features` interdit : une divergence de +features est silencieuse et ruine les prévisions sans erreur, une divergence sur une moyenne +d'écarts absolus est attrapée par le premier test à valeurs connues. + +### Ce qu'on mesure, et les deux dédoublonnages obligatoires + +La paire est `prediction ⋈ reading` sur `(site_id, target_at = timestamp)`, restreinte aux +prédictions `available`. Elle exige un `DISTINCT ON` **des deux côtés** : + +- `prediction` n'a pas d'unicité sur `(site_id, target_at)`, chaque run de scoring empile une + ligne. On retient la plus récente, celle que sert `GET /api/v1/predictions`, départagée par + `prediction_id` : `created_at` vaut l'heure de début de transaction et ne distingue pas deux + lignes du même run. +- `uq_reading_source` autorise deux lectures au même instant quand la `source` diffère. Sans + dédoublonnage, la jointure compterait cette heure deux fois et pondérerait doublement le site. + +La fenêtre est **fermée à droite par un délai de grâce de 2 h** : le réalisé de la dernière +heure n'est pas encore ingéré, et l'inclure ferait chuter le taux de couverture à chaque +exécution, pour une raison qui n'a rien à voir avec le modèle. + +Métriques retenues : `mae` (la métrique même qu'optimise LightGBM), **`bias` signé** (une MAE qui +monte dit « moins bon », un biais qui s'éloigne de zéro dit « le modèle se trompe toujours du +même côté », signature d'un décalage de distribution), `mape`, `n_observations`, +`coverage_ratio` et `insufficient_data_ratio` (qui mesurent le pipeline, pas le modèle), et la +liste des `model_references` vus dans la fenêtre : une MAE qui saute à l'instant exact où le +modèle change n'est pas une dérive, c'est une régression de réentraînement. + +## Alternatives écartées + +| Écartée | Raison | +|---|---| +| Comparer à la métrique MLflow de l'entraînement | Ce ne sont pas les mêmes grandeurs : `train.py` 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 est `NaN` et dont `is_working_hours` est recopié. Le verdict serait « dérive » dès le premier jour. Et le backend devrait importer `mlflow`, ce que la frontière de ML-START interdit. | +| Écrire le résultat dans `alert` | `ck_alert_source` et `ck_alert_type` bornent les valeurs autorisées, `alert.site_id` est `NOT NULL` et n'accueillerait donc pas la ligne globale, et toute alerte est ensuite relue par le moteur de recommandations, qui devrait apprendre une règle qui ne le concerne pas (ADR 0006). | +| Une jauge Prometheus | `monitoring/` ne contient que des `.gitkeep` et aucun collecteur ne lit `/metrics` : une jauge que personne ne scrute n'est pas une preuve. Le calcul est de surcroît un traitement par lot, pas le processus qui sert l'API : la jauge disparaîtrait avec lui. | +| Ne rien persister, journaliser seulement | La question posée à un jury est « comment savez-vous que le modèle se dégrade ? ». La réponse est une série dans le temps, pas une ligne de journal perdue avec le conteneur. Sans ligne écrite, l'endpoint n'a rien à lire et le test d'intégration rien à vérifier. | +| Une tâche de plus dans le DAG `alertes` | La fenêtre fait 168 h : la recalculer chaque heure écrirait vingt-quatre lignes identiques par jour. Surtout, un échec de dérive ferait rougir `alertes` et laisserait croire que la détection a échoué. | + +## Conséquences + +- Une migration ajoute `drift_report`. Son idempotence passe par un **index unique à + `coalesce(site_id, '')`** et non par une `UniqueConstraint` : deux lignes globales ont toutes + deux `site_id` à NULL, et NULL n'est égal à rien, pas même à lui-même. Même forme que + `uq_reading_source`. +- `GET /api/v1/monitoring/drift` est réservé à partir du rôle `operateur` : c'est l'opérateur + qui agit sur un pipeline dégradé, pas l'administrateur de comptes. La route est classée dans + `tests/api/acces.py`, donc couverte gratuitement par la matrice de rôles rejouée avec de vrais + jetons. +- Un DAG `derive` quotidien l'ordonnance, sans reprise : rejouer une dérive la redéclarerait à + l'identique. +- La CLI sort en code non nul sous `--fail-on-drift` seulement. Par défaut, constater une dérive + n'est pas un échec d'exécution. +- **Le biais ne fait pas basculer le verdict par défaut** : `Seuils.seuil_biais` vaut `0`, ce qui + désactive la règle. Le plafond de MAE se dérive de la fenêtre de référence, donc il vaut pour + n'importe quel site ; un seuil de biais, lui, s'exprime en kWh et ne se transpose pas d'un + bureau de 10 kWh à une usine de 1 000 kWh. En déclarer un sans l'avoir calibré sur la vraie + série ferait rougir la tâche sans rien prouver. Le `bias` signé reste calculé, stocké et servi + par `GET /api/v1/monitoring/drift` : il se lit, il ne juge pas encore. `--bias-threshold` + l'active site par site quand une valeur aura été mesurée. + +## Effet de bord assumé sur le pipeline + +La dérive n'a de matière que si des paires prévu/réalisé existent. Or `enervision_ml.score --now` +ne rejouait pas l'historique : `load_recent_from_database` n'avait pas de borne haute et +`build_scoring_frame` repartait de la dernière lecture connue, si bien que `target_at` valait +toujours « fin du jeu + 1 h » et que l'âge de la dernière lecture devenait négatif sans franchir +le seuil de péremption. Sur le jeu historique, figé au 31/12/2024, aucune boucle de rattrapage +n'aurait donc rien produit de vérifiable. + +`until` est devenu obligatoire sur ce chargeur, et le scoring lui passe son instant de référence. +Le comportement en exploitation ne change pas, aucune lecture n'étant postérieure à l'heure +courante ; seul le rattrapage sur données passées devient possible. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 7bdd1da..5d4ddb6 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -70,10 +70,11 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, intercepteur répond à sa place tant que les endpoints n'existent pas. Voir [30-frontend.md](30-frontend.md). -Le lien `airflow --> db` est maintenant en trait plein : trois DAGs tournent, deux pour +Le lien `airflow --> db` est maintenant en trait plein : cinq DAGs tournent, deux pour l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la -génération des recommandations (issue #116), cf. plus bas et [20-backend.md](20-backend.md). Le -reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit. +génération des recommandations (issue #116), `historical_import` pour le dataset historique +(issue #119) et `mock_api_import` pour l'ingestion horaire de l'API Mock (issue #15). +La réconciliation globale des données provenant des deux sources reste à compléter dans l'issue #15. Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun collecteur ne vient le lire. @@ -85,18 +86,20 @@ collecteur ne vient le lire. | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | -| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite | -| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée | +| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive livrée côté backend (`app.monitoring.drift`, table `drift_report`, `GET /monitoring/drift`, DAG `derive`), voir [ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md) | +| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | -| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG | -| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 5 workflows, 16 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) | +| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. La réconciliation globale des deux sources reste à compléter dans l'issue #15. | +| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 7 workflows, 19 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, formatage et validation du Terraform. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) | ## Flux bout en bout -Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin -d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`, -issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas -l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16). +Statut : `En cours`. **Le chemin de lecture tourne** entre la base, l'API et le frontend. +**Le chemin d'ingestion est maintenant orchestré par Airflow** : `historical_import` charge le +dataset CSV/JSON sur déclenchement manuel et `mock_api_import` collecte chaque heure les mesures +de l'API Mock. Les DAGs `ml_train` et `ml_score` (issue #115), `alertes` (issue #116) et `derive` +(issue #45) portent le pipeline ML, la détection d'alertes et la surveillance de dérive. La +réconciliation globale des données provenant des deux sources reste à compléter dans l'issue #15. ```mermaid sequenceDiagram @@ -186,3 +189,6 @@ Elles vivent dans `../adr/`, pas ici. | [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM | | [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` | | [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose | +| [0008](../adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement | +| [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé | +| [0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application | diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index c6121e7..1fbabda 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -1,13 +1,16 @@ # Infrastructure -Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans -quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles. +Plusieurs topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut +dans quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles. | Topologie | Sert à | Statut | |---|---|---| | Docker Compose | Développer et recetter sur le poste | `Fait` | | Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` | +| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` | +| Provisionnement Terraform de la VM | Préparer la machine et enregistrer le runner | `En cours` | | k3s single-node | Cible à terme | `En cours` | +| MLflow (`ml/`) | Tracker les expériences et le registre de modèles en local | `Fait`, non relié aux autres topologies | ## Poste de développement @@ -48,20 +51,28 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin - `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans le compose. Voir [`db/README.md`](../../db/README.md). -- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver : +- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais de l'api-server : c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow). -### Airflow (issues #115 et #116) +### Airflow (issues #15, #115, #116 et #119) -Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make +Quatre services (Airflow 3.3), `docker compose profiles` non utilisés (démarrage explicite via `make airflow-up`, pas dans `make dev`) : | Service | Rôle | Points notables | |---|---|---| -| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès | -| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même | +| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `api-server`, `dag-processor` et `scheduler` attendent qu'il se termine avec succès | +| `airflow-apiserver` | UI et API REST (`/api/v2`), port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même. Sert aussi l'Execution API que les tâches appellent, d'où le secret JWT partagé | +| `airflow-dag-processor` | Parse `dags/` et publie les DAGs sérialisés | Composant à part entière depuis Airflow 3 : le scheduler ne lit plus les fichiers de DAG | | `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` | +Airflow 3 impose deux choses que le compose reflète : les tâches ne touchent plus la base de +métadonnées et passent par l'Execution API de l'`api-server`, avec un jeton signé par +`AIRFLOW_JWT_SECRET` (secret partagé entre conteneurs, jamais celui généré au démarrage) ; et +l'authentification par défaut (`SimpleAuthManager`) ne sait pas créer de compte, d'où le +`FabAuthManager` qui garde le compte admin posé par `airflow-init`. Pas de `triggerer` : aucun +opérateur déférable dans les DAGs. + Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) : l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à @@ -75,6 +86,25 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md). | `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` | | `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` | | `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` | +| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` | +| `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe l'heure précédant son déclenchement depuis l'API Mock | +| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère | + +Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique. +Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage +`./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les +modifier. + +Le DAG `mock_api_import` exécute le pipeline API Mock toutes les heures, à la minute `:45`. +Un `CronTriggerTimetable` explicite lui attribue un intervalle d'une heure, y compris lors d'un +déclenchement manuel. Il transmet cet intervalle au script backend et charge les mesures dans +les tables communes `site` et `reading`. Le décalage à `:45` laisse quinze minutes avant le +scoring exécuté à l'heure pile, puis quinze minutes supplémentaires avant les alertes à `:15`. +`max_active_runs=1` empêche deux exécutions du DAG de se chevaucher. + +Le DAG conserve `catchup=False` pour éviter un rattrapage massif depuis sa date de démarrage. +Une interruption du scheduler peut donc créer un intervalle manquant, qui devra être rejoué +explicitement par une opération de backfill. **Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la `prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring @@ -95,14 +125,14 @@ rend contraignant. `airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et -`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin +`api-server`, `dag-processor` et `scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`). Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier entier avant de filtrer les services, une variable requise manquante casserait `make db-up`, `make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est -`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou -`AIRFLOW_APP_SECRET_KEY` vides). +`airflow-init` qui refuse de démarrer (clé Fernet, clé de session de l'API, secret JWT, mot de +passe ou `AIRFLOW_APP_SECRET_KEY` vides). Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est @@ -132,6 +162,34 @@ est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans qu (`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la construction de l'image. +### MLflow (`ml/`) + +Statut : `Fait`, en local uniquement. Défini par `ml/docker-compose.mlflow.yml`, indépendant +du `docker-compose.yml` principal (réseau, volumes et démarrage séparés). + +| Service | Image | Points notables | +|---|---|---| +| `mlflow-db` | `postgres:17` | Stocke le tracking store MLflow. Mot de passe obligatoire via `MLFLOW_DB_PASSWORD` | +| `mlflow` | Construite depuis `ml/` | Expose l'UI et l'API MLflow sur `127.0.0.1:5000`. Artefacts sur volume `mlflow-artifacts`, tracking store sur `mlflow-db` | + +Portée actuelle : environnement de tracking et de registre de modèles pour le développement +local uniquement. Ce compose n'est relié ni à `docker-compose.prod.yml`, ni aux deux +environnements Compose de la VM ENI, ni à la cible k3s. Le magasin utilisé par Airflow pour +`ml_train`/`ml_score` (SQLite, volume `airflow_ml_state`) en est distinct — les deux MLflow ne +se voient pas tant que `MLFLOW_TRACKING_URI` n'est pas posé côté Airflow. + +Limite connue : le DAG Airflow `ml_train` enregistre lui aussi une version a chaque execution +via `registered_model_name` (magasin SQLite du volume `airflow_ml_state`, distinct de ce +serveur). Versions et artefacts s'y accumulent sans politique de nettoyage -- fonctionne en +l'etat, mais a surveiller si les entrainements deviennent frequents. + +Pour relier les runs Airflow (`ml_train`, magasin SQLite local) a ce serveur MLflow, positionner +`MLFLOW_TRACKING_URI=http://mlflow:5000` dans l'environnement du service `airflow-scheduler` (ou +`http://host.docker.internal:5000` si le serveur MLflow tourne hors du reseau Compose principal), +et s'assurer que le conteneur Airflow peut joindre le service `mlflow` -- ce qui suppose de les +rapprocher sur le meme reseau Docker ou d'exposer MLflow autrement qu'en `127.0.0.1` uniquement +(cf. point 1 sur l'exposition du port). Non fait a ce jour : aucun besoin de centraliser les runs +d'entrainement Airflow et locaux n'a encore ete identifie. ## Machine cible, exécution Docker Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le @@ -169,10 +227,63 @@ Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent - `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP compte sur l'IP du proxy et devient globale. +### Deux environnements sur la même machine + +Statut : `En cours`, la machine n'étant pas encore provisionnée. Décision et motifs dans +l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md). +La VM `eadl-2025-nantes-g3` portera la recette et la production, chacune dans son clone du dépôt, +son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et conteneurs : rien +n'est partagé. `scripts/provision-host.sh` prépare les deux dossiers, génère les secrets et les +certificats, et ne démarre rien. + +| | Recette | Production | +|---|---|---| +| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` | +| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` | +| URL | `https://rec.enervision.local:8443` | `https://enervision.local` | +| Proxy HTTP, HTTPS | `127.0.0.1:8081`, `8443` | `80`, `443` | +| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` | + +Les deux noms d'hôte visent la même IP, à déclarer dans le `/etc/hosts` des postes. Deux noms +distincts sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port. +La redirection HTTP de la recette est ramenée sur la boucle locale parce que la configuration +Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production. + +Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur +la VM aligne le dossier sur la branche poussée et lance `make stack-up`. + +### Provisionnement de la machine + +Statut : `En cours`. Décision et frontière dans +l'[ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) : **Terraform +provisionne la machine, GitHub Actions déploie l'application**. La racine +`infra/terraform/environments/vm-eni/` fait trois choses, et rien d'autre. + +```mermaid +sequenceDiagram + participant TF as terraform apply + participant VM as VM eadl-2025-nantes-g3 + participant GH as GitHub + + TF->>VM: SSH, get.docker.com puis docker compose version + TF->>VM: copie et exécute scripts/provision-host.sh + VM->>VM: deux clones, deux .env, deux certificats + TF->>VM: installe actions-runner, config.sh, svc.sh + VM->>GH: le runner s'enregistre avec le label eni-g3 +``` + +Aucune image n'y est construite, aucun conteneur lancé : un `apply` n'interrompt pas la stack qui +tourne. Le premier démarrage reste manuel, `make stack-up` dans chaque dossier ; les suivants +sont joués par le runner à chaque push. Terraform ne sait rien de l'état de la stack, c'est la +sonde de `deploy.yml` qui le dit. + +Le jeton d'enregistrement du runner est valable une heure et ne vaut que pour une inscription : +l'`apply` n'est pas rejouable sans qu'un administrateur du dépôt en crée un nouveau. + ## Cible à terme, k3s -Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais -été appliqué. +Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la +racine `infra/terraform/environments/k3s-cible/`. Il n'a jamais été appliqué. ```mermaid flowchart LR @@ -220,13 +331,18 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de | `k3s_version` obligatoire, valeur vide refusée | Sans épinglage, `get.k3s.io` installe la dernière version à chaque exécution : le déploiement cesse d'être reproductible | `validation` dans `modules/k3s/variables.tf` | | Traefik désactivé | Le choix d'ingress reste ouvert, on ne veut pas en subir un par défaut | `k3s_disable_components`, défaut `["traefik"]` | | Kubeconfig laissé en `600/root`, lu par `sudo` | `--write-kubeconfig-mode 644` exposerait `cluster-admin` à tout utilisateur local de la machine | Commentaire et `fetch_kubeconfig` dans `modules/k3s/main.tf` | -| State Terraform en backend `local` | Un seul opérateur, pas d'exécution concurrente, pas de dépendance à un stockage distant | `environments/dev/versions.tf` | +| State Terraform en backend `local` | Un seul opérateur, pas d'exécution concurrente, pas de dépendance à un stockage distant | `versions.tf` de chaque racine | | `.terraform.lock.hcl` versionné | Fige les versions de provider entre contributeurs et future CI | Commentaire dans `.gitignore` | | `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` | | Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` | -| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` | +| Une racine Terraform par machine provisionnée, nommée d'après elle | `environments/dev` laissait croire à un environnement applicatif, alors que `rec` et `prod` vivent sur la même machine et ne sont pas provisionnés par Terraform | `environments/vm-eni`, `environments/k3s-cible` | +| Terraform provisionne, GitHub Actions déploie | Deux chemins pour le même acte de livraison, c'est ce que la revue de #141 relève sur la VM | [ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) | +| Connexion SSH par clé, jamais par mot de passe | Une variable de mot de passe finit en clair dans le state, ou dans les `triggers` qui y sont persistés | `environments/vm-eni/variables.tf`, `modules/k3s/main.tf` | | Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | | Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` | +| Un projet Compose par environnement, sur la même machine | Une seule VM, et l'isolation par nom de projet ne demande ni cluster ni registre | `.env` de chaque dossier, [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | +| Runner GitHub Actions auto-hébergé sur la VM | Les runners hébergés par GitHub ne joignent pas une adresse privée d'école | `.github/workflows/deploy.yml` | +| Secrets dans le `.env` de chaque environnement, sur la machine | Ni dans git, ni dans GitHub : le runner n'a rien à recevoir | `scripts/provision-host.sh` | ## Ports et noms @@ -237,12 +353,12 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de | API | `8000` | Identique en conteneur et hors conteneur | | Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` | | Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte | -| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`. 80 ne sert que la redirection et le défi ACME | +| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. 80 ne sert que la redirection et le défi ACME. La recette publie `8443` et `127.0.0.1:8081` | | SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable | | Base applicative | `enervision` | Variable `POSTGRES_DB` | | Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` | | Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` | -| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre | +| API server Airflow | `8080` | `make airflow-up`. Api-server, scheduler et dag-processor ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre | ## Le trou vers k3s @@ -261,4 +377,3 @@ question à trancher, avant toute ressource Kubernetes. - **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le cluster ou à côté. - **Quelle stratégie de sauvegarde et de restauration** des données de mesure. -- **Que devient `environments/prod/`**, aujourd'hui réduit à un `.gitkeep`. diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index c499dca..1b6f9fc 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,7 +12,7 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, readings, sensors, predictions"] + ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, readings, sensors,
predictions, monitoring"] sc["schemas
Pydantic"] sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, ReadingService, SensorService, PredictionService"] rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading, prediction"] @@ -151,6 +151,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | | GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | | GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 | +| GET | `/api/v1/monitoring/drift` | Dernier rapport de dérive par site, plus la ligne globale. `operateur` | 401, 403, 422, 500 | | GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | | GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | @@ -223,6 +224,30 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait côté pilote, en `500` plutôt qu'un refus propre. +### Surveillance de dérive + +`DriftService.evaluate()` joint `prediction` et `reading` sur `(site_id, target_at = timestamp)` +et compare deux fenêtres vives de 168 h, la récente et celle qui la précède. Il rend une ligne par +site plus une ligne globale, que `DriftRepository.enregistre()` écrit dans `drift_report` avec +`ON CONFLICT DO NOTHING` sur `uq_drift_report_window` : rejouer la commande sur la même fenêtre +n'ajoute rien. + +| Métrique | Ce qu'elle dit | +|---|---| +| `mae` | Erreur moyenne en kWh, la métrique même qu'optimise LightGBM | +| `bias` | Erreur moyenne **signée** : c'est elle qui distingue un modèle plus bruyant d'un modèle qui se trompe systématiquement du même côté. Lue et servie, elle ne fait basculer le verdict que sous `--bias-threshold`, faute d'un seuil en kWh transposable d'un site à l'autre ([ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md)) | +| `mape` | Comparable entre sites de tailles différentes, hors réalisés nuls | +| `coverage_ratio` | Part des prévisions disponibles qui ont trouvé leur réalisé : mesure le pipeline, pas le modèle | +| `insufficient_data_ratio` | Part des sites privés d'historique suffisant | +| `model_references` | Les modèles vus dans la fenêtre : une MAE qui saute à l'instant où le modèle change est une régression de réentraînement, pas une dérive | + +Le verdict a trois valeurs, `stable`, `derive` et `indetermine` : sous un nombre minimal +d'observations, le service dit qu'il ne sait pas plutôt que de rendre un chiffre trompeur. La +fenêtre est fermée à droite par un délai de grâce de 2 h, le temps que l'ingestion livre le +réalisé de la dernière heure. `python -m app.monitoring.drift` l'exécute, le DAG `derive` +l'ordonnance, et `GET /api/v1/monitoring/drift` sert le dernier rapport de chaque site. Les +arbitrages sont dans l'[ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md). + ### Détection d'alertes internes `AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour @@ -331,8 +356,10 @@ pas prise : | `license_info` | Aucune licence n'est choisie | | `contact` | Aucun canal de support n'existe | -Deux schémas de sécurité sont déclarés : `Jeton d'accès` pour le porteur JWT, et -`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement +Deux schémas de sécurité sont déclarés : `JetonAcces` pour le porteur JWT, et +`CookieRafraichissement` pour `/auth/refresh` et `/auth/logout`, des noms ASCII délibérés (issue +#41 : un outillage tiers comme ZAP peut mal analyser un nom de schéma accentué dans le contrat). +**Le second est purement documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le 401 sur lequel le frontend déclenche sa déconnexion. diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index 14a7012..4969b0b 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -4,39 +4,47 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps ## État actuel -Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée -par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API. +Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, la +supervision des capteurs (admin) et le flux des alertes actives, tous branchés sur l'API réelle. Ce qui est en place : - Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et - `provideHttpClient(withInterceptors([mockApiInterceptor]))`. -- Une route `/dashboard` en composant différé, et une redirection depuis la racine. -- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et - `AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification - (jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`, - `features/dashboard` la page principale, `shared/components` la jauge de consommation et le - graphique de charge par site, tous deux construits sur Chart.js. + `provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`. +- Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`, + `/monitoring/sensors` réservée au rôle `admin`) et une redirection depuis la racine. +- `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses + filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`, + `SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur + d'authentification (jeton porteur, rafraîchissement sur 401), `core/guards` la garde `authGuard`. +- `features/` porte une page par domaine. `shared/components` porte la jauge de consommation et + les graphiques Chart.js, le widget `app-alert-feed` (flux d'alertes filtrable par site et + sévérité, rafraîchi toutes les 60 s, première vue de l'application avec des états chargement / + vide / indisponible) et, dans `shared/models`, des types alignés sur les schémas Pydantic du + backend, plus les tables de présentation partagées (`alert-presentation.ts` : ton, libellé et + unité par sévérité, type et métrique). - Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation, - changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail : + changement de mot de passe, garde de route sur toute la zone authentifiée. Détail : [31-contrat-authentification.md](31-contrat-authentification.md). - Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`, - `ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit - réutiliser plutôt que redéfinir ses propres styles. Détail : + `ev-badge`, `ev-brand`, `ev-icon`, tokens CSS dans `styles/_tokens.scss`, classes globales de + formulaire, de navigation et de tableau) que toute nouvelle page doit réutiliser plutôt que + redéfinir ses propres styles. Détail : [32-design-systeme-frontend.md](32-design-systeme-frontend.md). - L'état vit dans des signaux, sans bibliothèque dédiée. +- TypeScript en `"strict": true` ; `strictTemplates` n'est pas encore activé. - Vitest via le builder `@angular/build:unit-test`, couverture activée. - Prettier configuré, parser `angular` pour les gabarits HTML. Ce qui n'existe pas encore : -- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` - sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien - ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement. - `GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette - PR (voir plus bas). -- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. +- **Le mode fixtures est inactif.** `useMockFixtures` vaut `false` dans `environment.ts` comme dans + `environment.development.ts` : `mockApiInterceptor` ne sert `/stats/summary` et `/alerts` que + dans son propre spec. En développement, toutes les pages exigent un backend joignable et un jeton + valide. +- Un état de chargement généralisé : seul `app-alert-feed` en a un, les autres pages restent vides + tant que la première réponse n'est pas arrivée. - Aucun lint : ESLint n'est pas installé. ## Arborescence @@ -87,11 +95,11 @@ sequenceDiagram ``` `mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si -`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en -production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. -`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il -suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide -et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche. +`environment.useMockFixtures` est vrai. Le drapeau vaut `false` dans les deux fichiers +d'environnement : en pratique toutes les requêtes suivent le chemin réel et l'intercepteur n'est +exercé que par son spec. `/predictions` et `/auth/*` ne sont de toute façon jamais mockés. En +développement, un jeton valide et un backend joignable sont donc nécessaires pour que le tableau de +bord s'affiche. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un @@ -146,6 +154,29 @@ Compose. Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md). +## Recommandations + +Statut : `Fait`. La vue `/recommendations` (`features/recommendations`, derrière `authGuard`, tous +rôles) présente les recommandations du moteur de règles groupées par alerte, du plus récent au plus +ancien, avec le contexte de l'alerte (sévérité, type, site, horodatage, message) puis chaque action, +son explication et la règle qui l'a produite. + +- **Jointure côté client.** Une recommandation ne porte que `alert_id`, jamais `site_id`, et + `GET /recommendations` n'a aucun filtre. `app-recommendation-list` (`shared/components/`) charge + donc en parallèle `GET /alerts` (filtré par `site_id` quand un site est fixé) et + `GET /recommendations`, puis les joint par `alert_id` (`joinByAlert`, fonction pure testée à + part). Les recommandations dont l'alerte n'est pas dans le jeu chargé sont ignorées : c'est ainsi + que le filtre site s'applique. `/alerts` n'étant pas paginé, un seul appel suffit. +- **Paramètres d'URL.** `?site=` présélectionne le filtre site ; `?alert=` + réduit la vue à une alerte et la met en évidence (entier strictement positif, sinon ignoré). +- **Génération.** Le bouton « Générer les recommandations » n'apparaît que pour le rôle `admin` + (`POST /recommendations/generate?site_id=`, réservé admin côté API) et affiche le bilan renvoyé + (créées, déjà présentes, alertes examinées) avant de recharger la liste. La voie normale reste le + DAG Airflow `alertes` ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). +- **Entrées.** Lien « Recommandations » dans l'en-tête du tableau de bord ; section + « Recommandations » sur la vue détail d'un site (liste restreinte au site, lien vers la vue + complète préfiltrée). + ## Questions ouvertes - **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs diff --git a/docs/architecture/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md index a7894d8..790c60f 100644 --- a/docs/architecture/32-design-systeme-frontend.md +++ b/docs/architecture/32-design-systeme-frontend.md @@ -20,16 +20,18 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai | `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) | | `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) | | `--font-family` | Police unique de l'application | +| `--font-size-xs` à `--font-size-2xl` | Échelle typographique (0.75rem à 2.25rem) : libellés, corps, titres, grands nombres | | `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) | -| `--shadow-card` | Ombre portée des cartes | +| `--shadow-card`, `--shadow-card-hover` | Ombre portée des cartes, au repos et au survol | | `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) | -Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans -`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles -s'appliquent directement à des `
Nom{{ site.site_type }} {{ site.location || '-' }} {{ site.capacity_kw ?? '-' }}{{ site.status ?? '-' }} + {{ site.status ?? '-' }} + Détail
`, `.ev-table__number` pour une cellule numérique en chiffres +tabulaires, `.ev-table__muted` pour une cellule sans valeur. ## Logo diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 82f769b..335bafd 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -48,7 +48,8 @@ Statut : `Fait`. et refuse de s'appliquer si l'extension TimescaleDB manque. - Les révisions suivantes créent les tables liées à l'authentification : `app_user`, `login_attempt`, `audit_log` et `refresh_token`. -- La révision `e6d2026091501` crée les six tables Data et déclare l'hypertable `reading`. +- La révision `e6d2026091501` crée six des sept tables Data et déclare l'hypertable `reading`. +- La révision `d3f1a2b7c904` ajoute `drift_report`, la septième. - La révision `c0adab96238c` ajoute les tables `password_reset_attempt` et `password_reset_token`. @@ -261,8 +262,8 @@ Cette modélisation prend en compte : - leurs métadonnées JSON ; - les données de l'API Mock. -Elle comprend six tables Data, depuis le stockage des mesures jusqu'aux recommandations proposées -à l'utilisateur. +Elle comprend sept tables Data, depuis le stockage des mesures jusqu'aux recommandations +proposées à l'utilisateur, et jusqu'au suivi de la dérive du modèle. ### Schéma de données @@ -286,11 +287,22 @@ Chaque table remplit un rôle précis dans le traitement et l'exploitation des d | `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision | | `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | | `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision | +| `drift_report` | Suivre l'écart entre prévisions et réalisé, par site et tous sites confondus | Surveillance de dérive d'EnerVision | +Le scoring (`ml_score`) charge le modèle depuis un fichier local (`models/lightgbm-consumption.txt`) +et trace son empreinte SHA-256 dans `prediction.model_reference`. Il ne lit aucune version depuis +le Model Registry MLflow (`ml/`) : ce registre sert aujourd'hui à la traçabilité des +entraînements, pas au déploiement du modèle de scoring. Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`. Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles. +Les lignes de `drift_report` sont écrites par `app.monitoring.drift`, ordonnancé par le DAG +`derive`. Une ligne dont le `site_id` est `NULL` porte le résultat global, tous sites confondus : +c'est pourquoi l'unicité passe par un index sur `coalesce(site_id, '')` et non par une contrainte, +qui ne dédoublonnerait jamais deux lignes globales. Le calcul, ses seuils et ce qu'il refuse de +comparer sont dans l'[ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md). + Les lignes de `recommendation` sont écrites par le moteur de règles du backend (`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`, par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en @@ -304,6 +316,7 @@ n'ajoute aucune ligne. - Les mesures API ne sont pas rattachées à un dataset historique. - Une alerte peut être associée à une prévision du même site. - Une alerte peut donner lieu à plusieurs recommandations. +- Un site possède plusieurs rapports de dérive ; un rapport global n'est rattaché à aucun site. ## Ingestion des données historiques diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index 79313a3..9a80102 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -6,11 +6,21 @@ vérifié, ce qui bloque, et ce qui ne l'est pas. | Étage | Sert à | Statut | |---|---|---| | Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` | -| Livraison continue | Porter un artefact vérifié jusqu'à la machine de déploiement | `Cible` | +| Livraison continue | Déployer chaque branche d'intégration sur son environnement de la VM ENI | `En cours` | -Le **D** de CI/CD n'existe pas encore : aucun job de déploiement, aucune construction d'image -publiée, aucun environnement GitHub. L'issue #21 le porte. C'est la limite principale de cet -étage, et elle est nommée ici plutôt que découverte en soutenance. +Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en +production sur la VM de l'école, par un runner auto-hébergé (issue #21, +[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Il n'a encore rien +déployé : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Statut à +basculer sur `Fait` au premier déploiement vert. Sa limite, nommée ici plutôt que découverte en +soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact +n'est publié puis promu d'un environnement à l'autre. + +Ce que ce workflow ne fait pas, et ne fera pas : préparer la machine. Installation de Docker, +clones, `.env`, certificats et enregistrement du runner sont provisionnés par +`infra/terraform/environments/vm-eni` +([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Terraform provisionne, +GitHub Actions déploie ; aucun des deux ne fait le travail de l'autre. ## Vue d'ensemble @@ -41,9 +51,14 @@ flowchart TB ab["image
construction de l'image"] end + subgraph infw["Infra · infra.yml"] + it["terraform
fmt -check, init et validate par racine"] + end + subgraph sq["SonarQube · sonarqube.yml"] sb1["build-front / test-front"] sb2["build-back / test-back"] + sb3["test-ml"] sscan["sonarqube
quality gate SonarCloud"] end @@ -52,17 +67,36 @@ flowchart TB push --> fd push --> mv & ms push --> av & ab - push --> sb1 & sb2 --> sscan - sscan -.-> cd["deploy
issue #21"] + push --> it + push --> sb1 & sb2 & sb3 --> sscan + + subgraph cd["Déploiement · deploy.yml"] + dep["deploy
runner eni-g3, environnement rec ou prod"] + end + + push -->|"push sur dev ou main"| dep + + planifie["chaque lundi 3h UTC,
ou à la main"] + subgraph dastw["DAST · dast.yml"] + zscan["zap
seed + scan actif OWASP ZAP"] + end + + planifie --> zscan + push -->|"PR sur dast.yml
ou dast-token.sh"| zscan ``` ## Déclenchement -Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** : -`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`, -`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun -incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline -déclenche le pipeline. +Les six workflows hébergés par GitHub qui vérifient le code se déclenchent sur `push` **et** sur +`pull_request`, filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur +`apps/frontend/**`, `ml.yml` sur `ml/**`, `infra.yml` sur `infra/terraform/**`, `airflow.yml` sur +`etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun incluant son propre +fichier de workflow dans le filtre pour qu'une modification du pipeline déclenche le pipeline. + +`dast.yml` s'en écarte volontairement (détail dans sa propre section plus bas) : aucun +déclenchement sur `push`, seulement `workflow_dispatch`, une planification hebdomadaire, et +`pull_request` restreint à ses deux seuls fichiers. Un scan actif est trop long pour tourner à +chaque commit. Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`, `ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et @@ -80,9 +114,51 @@ rien changer), mais ce serait à borner sur un dépôt à forte fréquence de pu `backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent. -**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14, parce qu'Airflow 2.10 -ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image -Docker et son propre environnement. +**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14 : c'est l'interpréteur +de l'image `apache/airflow:3.3.2-python3.12` retenue, et les tests d'intégrité doivent tourner sur +le même. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image Docker et son propre +environnement. + +## Déploiement + +`deploy.yml` est le huitième workflow (`backend`, `frontend`, `ml`, `infra`, `airflow`, +`sonarqube`, `dast`, plus lui-même), et le seul qui ne tourne pas chez GitHub : il s'exécute sur +un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne +joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port +entrant n'est ouvert. + +| Événement | Environnement GitHub | Dossier sur la VM | Garde | +|---|---|---|---| +| `push` sur `dev` | `rec` | `/srv/enervision/rec` | aucune : la recette suit `dev` | +| `push` sur `main` | `prod` | `/srv/enervision/prod` | approbation d'un relecteur dans l'environnement `prod`, branche `main` seule autorisée | + +Le job aligne le clone sur la branche (`fetch`, `checkout`, `reset --hard`), lance +`make stack-up`, qui reconstruit les images, redémarre les conteneurs puis applique les +migrations Alembic dans le conteneur backend, et attend jusqu'à trois minutes que +`/api/v1/health/ready` réponde derrière le proxy. Cette sonde ne vérifie que la connexion à la +base et la présence de TimescaleDB : sans la migration, le déploiement serait vert sur une base +sans schéma, et c'est pourquoi `make stack-up` la porte. Un groupe de concurrence par branche, +sans annulation, empêche deux déploiements simultanés du même environnement. + +Le job ne fait pas de `actions/checkout` dans son espace de travail, et c'est voulu : le dossier +de l'environnement est stable, hors du runner, parce que `.env`, certificats et volumes doivent +survivre d'un déploiement à l'autre. + +**Piège à connaître.** Un runner auto-hébergé sur un dépôt public exécute ce qu'un workflow lui +envoie, et une PR de fork peut réécrire un workflow. Trois parades, et les trois sont +nécessaires : `deploy.yml` ne se déclenche jamais sur `pull_request` ; le runner tourne sous un +utilisateur dédié membre du groupe `docker`, jamais root ; le dépôt doit exiger une approbation +pour les workflows des PR externes (Settings, Actions, « Require approval for all outside +collaborators »), ce qui reste à activer. Les workflows de CI restent sur `ubuntu-latest`. + +Cet utilisateur dédié doit posséder `/srv/enervision` : sinon git refuse les deux clones pour +propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=` +passé à `scripts/provision-host.sh` fixe ce propriétaire. + +La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou +plus, clone les deux branches, génère les secrets de chaque `.env` et les certificats +auto-signés, et ne démarre rien. Le détail des deux environnements, ports et noms d'hôte, est +dans [10-infra.md](10-infra.md). ## Ce qui bloque un merge @@ -93,6 +169,8 @@ Docker et son propre environnement. | Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque | | Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque | | Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque | +| Tests d'intégration ML ↔ DB | ml | marqueur `integration`, base réelle migrée par Alembic | Bloque | +| Chaîne ML → DB → API | ml | marqueur `chaine`, vrais binaires en sous-processus | Bloque | | Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque | | Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque | | **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque | @@ -100,6 +178,7 @@ Docker et son propre environnement. | Build `npm run build` | frontend | compilation | Bloque | | Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque | | Construction de l'image Airflow | airflow | `docker build` de `etl/airflow/Dockerfile` | Bloque | +| Formatage et validité Terraform | infra | `fmt -check -recursive`, puis `init` et `validate` par racine | Bloque | Deux seuils portent une décision qu'il faut savoir défendre : @@ -130,12 +209,42 @@ avant `alembic upgrade head`. La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %. +### Pourquoi le job d'intégration ML installe aussi le backend + +Le schéma de la base n'a qu'une source, les six révisions Alembic de `apps/backend/alembic` : le +backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la +main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur +qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe +donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté +`ml/` et `-m chaine` côté backend. + +Conséquence sur le déclenchement : les `paths` de `ml.yml` incluent `apps/backend/alembic/**` et +`apps/backend/app/models/**`. Sans eux, une migration qui renomme une colonne de `reading` ne +déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait +avant la production**. Le prix est qu'une PR touchant seulement une migration lance aussi le lint +et le typage de `ml/` : environ deux minutes de runner, en parallèle. Même arbitrage que le filtre +d'`airflow.yml`, qui écoute déjà `ml/**` et `apps/backend/app/**` parce que son image réunit les +deux. + +Le marqueur `chaine` est distinct d'`integration` pour une raison mécanique : le job `integration` +de `backend.yml` n'installe pas `ml/.venv`, et sélectionnerait sinon un test qui lance les +binaires du pipeline. Il est aussi exclu d'`addopts`, sans quoi `make test` échouerait sur tout +poste où `ml/` n'est pas installé. + ## SonarCloud, et l'incident qui a immobilisé trois PR -Le workflow `sonarqube.yml` exécute quatre jobs de préparation (`build-front`, `test-front`, -`build-back`, `test-back`) qui produisent chacun un rapport de couverture en artefact, puis un -cinquième job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8` avec le secret -`SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine. +Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`, +`build-back`, `test-back`, `test-ml`) dont les tests produisent chacun un rapport de couverture en +artefact, puis un dernier job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8` +avec le secret `SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine. + +Le périmètre couvre `apps/frontend`, `apps/backend`, `ml/` et `etl/airflow` (les deux derniers +ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas +Sonar). `ml/` publie `ml/coverage.xml` (`pytest-cov`, même mécanisme que le backend, sans seuil +propre : la gate porte sur le code neuf). `etl/airflow` est exclu de la **couverture** +(`sonar.coverage.exclusions`) : ses tests ne font que charger les DAGs, ils ne mesurent rien. +Piège : tout nouveau dossier de tests doit être déclaré dans `sonar.tests`, faute de quoi il est +compté comme code de production non couvert (cf. l'incident ci-dessous). **L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que @@ -171,19 +280,124 @@ les tests ne se merge pas. ## Secrets -Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions. +Un seul secret est consommé côté GitHub : **`SONAR_TOKEN`**, porté par les secrets du dépôt. Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le -run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement : le job de -déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par -l'issue #22. +run. + +Le déploiement ne consomme **aucun secret GitHub** (issue #22). Les secrets de chaque +environnement, mots de passe PostgreSQL et Airflow, clés de signature, clé Fernet, vivent dans le +`.env` de son dossier sur la VM, en `600`, générés sur la machine par `scripts/provision-host.sh`. +Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce dossier, n'a rien +à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se +régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow. + +## Scan DAST (OWASP ZAP) + +Statut : `En cours`. Le workflow `dast.yml` attaque l'API **en fonctionnement**, ce que ni Bandit, +ni `pip-audit`, ni Sonar ne font. Il se lance à la main (`workflow_dispatch`), chaque lundi à 3h +UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan actif dure plusieurs +minutes. + +Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base +jetable), applique les migrations, y sème un site et deux relevés (`db/seeds/` est vide, pas +encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque +`/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur), démarre le +backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jeton. + +ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t +/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le +contrat qui décide de ce qui est exploré, pas l'authentification. Le jeton ne change que les +réponses obtenues sur les routes gardées : sans lui, elles répondraient toutes `401` plutôt que +de dérouler leur logique. Huit routes n'exigent aucun jeton porteur (les deux sondes, `login`, +`refresh`, `logout`, `forgot-password`, `reset-password` et `reset-password/validate`) et +répondent donc pareil avec ou sans lui. + +Décisions à savoir défendre : + +- **Le compte du scan est `lecteur`, jamais `admin`.** Un scan actif avec un jeton admin frapperait + `POST /users` et la réinitialisation de mots de passe pour de bon. Le script passe par un admin + jetable pour créer le lecteur (l'API n'a pas d'inscription publique) puis ne s'en sert plus. +- **Un compte neuf est en `must_change_password`**, et toute route gardée le refuse tant que le + mot de passe n'est pas changé. Le script fait ce changement et vérifie `GET /sites` = 200 avant + de rendre le jeton ; sans cela, tout le scan authentifié ne testerait que des `403`. + `POST /auth/password` rend déjà un nouveau jeton valide (l'`iat` tronqué documenté dans + `app/api/deps.py` ne le rejette pas comme antérieur à la session) : le script s'en sert + directement plutôt que de se reconnecter, deux hachages Argon2id (19456 Kio chacun) et deux + allers-retours de refresh-token de moins sur le chemin critique de la CI. +- **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut + dure 15 minutes. `scanner.maxScanDurationInMins=15` (ci-dessous) borne le scan actif très en + dessous, marge comprise pour les étapes qui l'entourent. +- **Le jeton ne transite ni par `${{ }}` dans le script de l'étape, ni par l'argv de `docker + run`.** Le premier finirait en clair dans le fichier de commande que GitHub écrit sur le disque + du runner pour toute la durée de l'étape ; le second serait 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é (`-configfile`), monté en lecture seule hors de `/zap/wrk` pour ne + jamais atterrir dans l'artefact publié. ZAP journalise malgré tout la valeur de chaque + `-config`/`-configfile` chargé à un niveau visible sans `-d` : les copies de `zap.log` et + `zap-stdout.log` publiées en artefact sont donc caviardées avant publication. + +**Deux pièges d'autorisation** sur ce fichier de configuration (`zap-auth.conf`), tous les deux +propres au montage bind Docker : le conteneur y lit avec son propre uid (1000), distinct de celui +du runner qui l'a écrit, sans remappage automatique. + +- Un `chmod 600` seul rend le fichier illisible pour le conteneur (« File not readable : + /zap/auth.conf »). ZAP échoue dès le lancement, mais `zap-api-scan.py` attend les `-T` minutes + complètes avant d'abandonner : dix minutes qui ressemblent à un scan actif, pour un daemon mort + depuis le début. Corrigé par `sudo chown 1000:1000` du fichier avant de le passer à `644`. +- Ce `chown` déplace la propriété du fichier hors de l'utilisateur du runner : un `chmod` qui + suit sans `sudo` échoue alors (« Operation not permitted »), et le `-e` implicite des étapes + bash de GitHub Actions arrête toute l'étape avant même `docker run` — un scan « réussi » en une + fraction de seconde, sans le moindre journal ni rapport produit. Les deux commandes doivent + passer par `sudo`. + +Les routes d'authentification qui changent l'état du compte (`login`, `password`, `logout-all`, +`forgot-password`, `reset-password`) sont exclues du scan actif : elles y déclencheraient la +limitation de débit et fermeraient les sessions sans rien apprendre de plus. + +**Un scan vert n'est pas un scan qui a testé quelque chose.** Deux garde-fous, eux, **bloquent** : + +- **Moins de 80% des opérations du contrat importées.** Constaté une première fois : 2 URL sur 26 + opérations importées, ZAP n'avait envoyé que des requêtes vouées au 404 (l'analyseur de ZAP + refusait alors le nom accentué d'un des deux schémas de sécurité du contrat, corrigé depuis en + ASCII côté backend). Le seuil est dérivé du contrat (`zap-out/openapi.json`, présent à cette + étape) plutôt que d'un nombre fixe : un contrat qui grossit ne doit pas rendre la garde plus + permissive qu'elle ne l'était. +- **Aucune réponse 2xx.** Constaté une deuxième fois, cause différente : la clé de configuration + du nom d'en-tête pour la règle Replacer est `matchstr`, pas `matchstring` (celui-ci n'existe que + pour le job d'automatisation ZAP, pas pour `-config`) ; ZAP acceptait la mauvaise clé sans + erreur et laissait le nom d'en-tête vide, qu'uvicorn refusait par un `400` sur **toute** requête, + y compris les routes publiques. Piège de conception rencontré en corrigeant cette garde : borner + le *pourcentage* de 4xx ne marche pas, un scan actif fuzze délibérément un grand nombre + d'entrées invalides, si bien qu'un scan sain contre l'API seedée reste à 98% de 4xx avec + seulement 1% de 2xx. C'est la forme normale d'un scan actif. Le signal qui distingue vraiment un + scan cassé (2xx nul, absent du rapport dans les deux incidents) d'un scan sain (2xx non nul, + aussi faible soit-il) est l'absence de succès, pas la part d'échecs. Les deux gardes lisent + `zap-out/zap-report.json` (champs structurés `insights[]`), pas le texte libre du rapport + Markdown. + +Le journal interne de ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans +l'artefact `zap-report` (dossier `zap-logs/`, propriété du runner : `zap-out/` bascule sous l'uid +1000 du conteneur ZAP dès que le contrat y est copié, le runner n'y écrit plus ensuite) pour +diagnostiquer un futur import raté. + +**Non bloquant pour l'instant** (`continue-on-error`, sur la seule étape du scan) pour ce qui est +des alertes elles-mêmes. Le volume d'un premier passage trié est inconnu ; le rapport +HTML/JSON/Markdown est publié en artefact `zap-report`, et sa synthèse (jusqu'aux tableaux +d'alertes, sans le détail par alerte) dans le résumé du job. Fixer un seuil viendra une fois les +alertes triées. + +**Limite à ne pas oublier :** le scan tape la configuration par défaut du backend (`APP_ENV=local`, +pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas derrière le proxy +(HSTS absent...) et ne dit **rien** des en-têtes ni du TLS que le proxy pose en production. Un +second passage sur la stack complète reste à faire. ## Ce qui manque, et pourquoi | Manque | Issue | Conséquence assumée | |---|---|---| -| Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine | -| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances | +| Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit | +| DAST bloquant | #41 | Le scan ZAP existe mais ne bloque rien : aucun seuil n'est fixé tant que les alertes du premier passage ne sont pas triées | | Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI | | Tests de charge | #47 | Aucun garde-fou de performance | | Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés | @@ -191,8 +405,17 @@ l'issue #22. ## Reproduire la CI en local `make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job -`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration -demandent une base : `make db-up` puis `uv run pytest -m integration`. +`verification`. `make ml-check` fait la même chose pour le module ML. + +Les tests d'intégration demandent une base **migrée**, et `db/init` ne crée `enervision_test` que +vide : + +```bash +make db-up migrate-test # la base de test reçoit les six révisions Alembic +make test-integration # backend, marqueur `integration` +make ml-test-integration # pipeline ML, marqueur `integration` +make test-chaine # vrais binaires ML puis relecture par l'API, marqueur `chaine` +``` Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index 96df248..7202cfa 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -38,6 +38,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints | Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 | | Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 | | Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 | +| Scan dynamique OWASP ZAP de l'API authentifiée (compte `lecteur` jetable), non bloquant, configuration par défaut du backend uniquement (ni TLS ni en-têtes du reverse proxy) | `.github/workflows/dast.yml`, `scripts/dast-token.sh` | A05, API8 Security Misconfiguration | | En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 | | Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design | | Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 | diff --git a/etl/README.md b/etl/README.md index 698ffca..94f8ba2 100644 --- a/etl/README.md +++ b/etl/README.md @@ -663,8 +663,22 @@ mock_api_import.py La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP. -Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline ML (`ml_train`/`ml_score`, issue #115) ainsi que la détection d'alertes et la génération des recommandations (`alertes`, issue #116). Il n'orchestre pas encore ces deux imports : `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) restent à faire. +Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre cinq DAGs : +le pipeline ML (`ml_train` et `ml_score`, issue #115), la détection d'alertes et la génération +des recommandations (`alertes`, issue #116), l'import historique (`historical_import`, +issue #119) et l'import périodique de l'API Mock (`mock_api_import`, issue #15). -Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui). +Le DAG `mock_api_import` s'exécute chaque heure, à la minute `:45`. Il appelle +`app.etl.mock_api_import` avec un intervalle explicite d'une heure et une limite de 1 000 lectures +par site. Les deux pipelines normalisent leurs données vers les tables communes `site` et +`reading`, tout en conservant leur source (`csv` ou `api_history`). La réconciliation globale +des deux sources reste à compléter dans l'issue #15. + +Le DAG `mock_api_import` exécute `app.etl.mock_api_import` toutes les heures. Chaque exécution +traite l'intervalle Airflow précédent. Les deux pipelines normalisent leurs données vers les +tables communes `site` et `reading`, tout en conservant leur source (`csv` ou `api_history`). + +Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py`, `alertes.py`, `historical_import.py` et +`mock_api_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui). Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. diff --git a/etl/airflow/Dockerfile b/etl/airflow/Dockerfile index b934588..4212839 100644 --- a/etl/airflow/Dockerfile +++ b/etl/airflow/Dockerfile @@ -1,9 +1,9 @@ # Image Airflow EnerVision : ajoute ml/ et apps/backend/ dans leurs propres environnements Python -# 3.14, distincts du Python 3.12 qui fait tourner Airflow lui-meme (apache-airflow 2.10 ne supporte -# pas 3.14), pour que les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`, +# 3.14, distincts du Python 3.12 de l'image de base qui fait tourner Airflow lui-meme, pour que +# les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`, # `app.detection.internal_alerts` et `app.cli` en sous-processus. Airflow ne devient jamais un # consommateur direct de LightGBM, de MLflow ou du SQLAlchemy du backend. Cf. ADR 0008. -FROM apache/airflow:2.10.4-python3.12 +FROM apache/airflow:3.3.2-python3.12 # LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans # toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file` @@ -21,9 +21,8 @@ RUN apt-get update \ RUN mkdir -p /opt/ml/state /opt/backend && chown -R airflow:root /opt/ml /opt/backend USER airflow -# L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de -# `ml/uv.lock`. On le remplace par la version deja pinnee ailleurs dans le depot -# (apps/backend/Dockerfile). +# L'image de base embarque deja un `uv`, mais pas celui que le depot epingle par ailleurs +# (apps/backend/Dockerfile) : on aligne, pour que le format de verrou lu soit le meme partout. COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv # Piege : pas de `UV_PROJECT_ENVIRONMENT` global. Il vaudrait pour les deux projets, et `uv run` diff --git a/etl/airflow/dags/alertes.py b/etl/airflow/dags/alertes.py index 4c043d2..e2f0587 100644 --- a/etl/airflow/dags/alertes.py +++ b/etl/airflow/dags/alertes.py @@ -15,8 +15,8 @@ from __future__ import annotations from datetime import datetime, timedelta -from airflow.models.dag import DAG -from airflow.operators.bash import BashOperator +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG # Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et # `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement. diff --git a/etl/airflow/dags/derive.py b/etl/airflow/dags/derive.py new file mode 100644 index 0000000..ba9b973 --- /dev/null +++ b/etl/airflow/dags/derive.py @@ -0,0 +1,46 @@ +"""DAG de surveillance de la dérive du modèle de prévision (issue #45). + +Quotidien, pas horaire : la fenêtre mesurée couvre 168 h, la recalculer chaque heure écrirait +vingt-quatre lignes presque identiques par jour et se heurterait à l'index d'idempotence +`uq_drift_report_window`. Planifié après les scorings de la nuit, et décalé de `ml_score` (à +l'heure pile) comme de `alertes` (à la quinzième minute). + +Tâche distincte du DAG `alertes` plutôt qu'ajoutée à lui : un échec de dérive y ferait croire +que la détection d'alertes a échoué, et ce DAG porte un budget temporel déjà argumenté face à +son pas horaire. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG + +# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et +# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement. +COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m" + +# Piège : aucune reprise. Une dérive n'est pas un échec transitoire, la rejouer la redéclarerait +# à l'identique ; et la cadence quotidienne pardonne une connexion perdue. +TENTATIVES = 0 +PLAFOND = timedelta(minutes=10) + +with DAG( + dag_id="derive", + description=( + "Compare les prévisions déjà écrites aux lectures réellement arrivées " + "(app.monitoring.drift)." + ), + schedule="30 5 * * *", + start_date=datetime(2026, 1, 1), + catchup=False, + max_active_runs=1, + tags=["ml", "monitoring"], +) as dag: + BashOperator( + task_id="derive", + bash_command=f"{COMMANDE_BACKEND} app.monitoring.drift", + retries=TENTATIVES, + execution_timeout=PLAFOND, + ) diff --git a/etl/airflow/dags/historical_import.py b/etl/airflow/dags/historical_import.py new file mode 100644 index 0000000..30d1a15 --- /dev/null +++ b/etl/airflow/dags/historical_import.py @@ -0,0 +1,45 @@ +"""DAG d'import du dataset historique EnerVision (issue #119). + +Orchestre le pipeline existant `app.etl.historical_import` sans dupliquer sa logique ETL. +Le dataset historique sert à initialiser l'environnement : le DAG reste donc manuel. + +Le backend est exécuté dans l'environnement `/opt/backend` embarqué dans l'image Airflow, +sur le même patron que le DAG `alertes` (ADR 0008). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG + +COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m" + +CSV_PATH = "/opt/data/raw/all_sites_combined.csv" +METADATA_PATH = "/opt/data/raw/dataset_metadata.json" +SOURCE_TIMEZONE = "UTC" +BATCH_SIZE = 1000 + +with DAG( + dag_id="historical_import", + description="Importe le dataset historique CSV/JSON dans dataset, site et reading.", + schedule=None, + start_date=datetime(2026, 1, 1), + catchup=False, + max_active_runs=1, + tags=["etl", "historical"], +) as dag: + BashOperator( + task_id="import_historical", + bash_command=( + f"{COMMANDE_BACKEND} app.etl.historical_import " + f"--csv {CSV_PATH} " + f"--metadata {METADATA_PATH} " + "--source-timezone UTC " + "--batch-size 1000" + ), + retries=1, + retry_delay=timedelta(minutes=2), + execution_timeout=timedelta(minutes=30), + ) diff --git a/etl/airflow/dags/ml_score.py b/etl/airflow/dags/ml_score.py index 8d6cfeb..30d0ee7 100644 --- a/etl/airflow/dags/ml_score.py +++ b/etl/airflow/dags/ml_score.py @@ -10,8 +10,8 @@ from __future__ import annotations from datetime import datetime, timedelta -from airflow.models.dag import DAG -from airflow.operators.bash import BashOperator +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt" diff --git a/etl/airflow/dags/ml_train.py b/etl/airflow/dags/ml_train.py index d866480..132aacd 100644 --- a/etl/airflow/dags/ml_train.py +++ b/etl/airflow/dags/ml_train.py @@ -11,8 +11,8 @@ from __future__ import annotations from datetime import datetime, timedelta -from airflow.models.dag import DAG -from airflow.operators.bash import BashOperator +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt" MLFLOW_TRACKING_URI = "sqlite:////opt/ml/state/mlflow.db" diff --git a/etl/airflow/dags/mock_api_import.py b/etl/airflow/dags/mock_api_import.py new file mode 100644 index 0000000..f0043d0 --- /dev/null +++ b/etl/airflow/dags/mock_api_import.py @@ -0,0 +1,61 @@ +"""DAG d'import périodique des données de l'API Mock EnerVision (issue #15). + +Orchestre le pipeline existant `app.etl.mock_api_import` sans dupliquer sa logique ETL. +Chaque exécution traite l'heure précédant son déclenchement. + +Le pipeline backend reste responsable de la validation, de la normalisation, du suivi de la +qualité, de l'idempotence et du chargement dans PostgreSQL/TimescaleDB. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG +from airflow.timetables.trigger import CronTriggerTimetable + +# Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008). +COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m" + +# Le pipeline backend et l'API acceptent au maximum 1 000 lectures par site. +# Cette marge évite de perdre silencieusement une lecture si une heure en contient plus de 60. +LIMITE_LECTURES = 1000 + +# Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste +# inférieure au pas horaire du DAG. +NOMBRE_REPRISES = 2 +DELAI_ENTRE_REPRISES = timedelta(minutes=2) +PLAFOND_PAR_TENTATIVE = timedelta(minutes=10) + +# L'intervalle est déclaré explicitement pour ne pas dépendre de la valeur du paramètre Airflow +# `create_cron_data_intervals`. Le déclenchement à :45 laisse quinze minutes avant `ml_score`, +# exécuté à l'heure pile, puis avant `alertes`, exécuté à :15. +PLANIFICATION = CronTriggerTimetable( + "45 * * * *", + timezone="UTC", + interval=timedelta(hours=1), +) + +with DAG( + dag_id="mock_api_import", + description="Importe chaque heure les données de l'API Mock dans site et reading.", + schedule=PLANIFICATION, + start_date=datetime(2026, 1, 1), + catchup=False, + # Deux exécutions simultanées pourraient demander et traiter le même intervalle. + max_active_runs=1, + tags=["etl", "mock-api"], +) as dag: + BashOperator( + task_id="import_mock_api", + bash_command=( + f"{COMMANDE_BACKEND} app.etl.mock_api_import " + "--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\" " + "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\" " + f"--limit {LIMITE_LECTURES}" + ), + retries=NOMBRE_REPRISES, + retry_delay=DELAI_ENTRE_REPRISES, + execution_timeout=PLAFOND_PAR_TENTATIVE, + ) diff --git a/etl/airflow/pyproject.toml b/etl/airflow/pyproject.toml index a792f9f..b3df80d 100644 --- a/etl/airflow/pyproject.toml +++ b/etl/airflow/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "DAGs d'orchestration EnerVision (Airflow)" requires-python = ">=3.12,<3.13" dependencies = [ - "apache-airflow==2.10.4", + "apache-airflow==3.3.2", ] [dependency-groups] diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index 555849d..d03dd97 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -1,27 +1,38 @@ """Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition.""" -from datetime import timedelta +from datetime import datetime, timedelta from pathlib import Path import pytest -from airflow.models.baseoperator import BaseOperator -from airflow.models.dagbag import DagBag +from airflow.dag_processing.dagbag import DagBag +from airflow.sdk import BaseOperator +from airflow.timetables.trigger import CronTriggerTimetable DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags" -DAG_IDS = ["ml_train", "ml_score", "alertes"] +DAG_IDS = [ + "ml_train", + "ml_score", + "alertes", + "historical_import", + "mock_api_import", + "derive", +] TACHES = [ ("ml_train", "train"), ("ml_score", "score"), ("alertes", "detection"), ("alertes", "recommandations"), + ("historical_import", "import_historical"), + ("mock_api_import", "import_mock_api"), + ("derive", "derive"), ] @pytest.fixture(scope="module") def dagbag() -> DagBag: - return DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False) + return DagBag(dag_folder=str(DAGS_FOLDER)) def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None: @@ -33,18 +44,35 @@ def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None: def test_ml_train_has_no_schedule(dagbag: DagBag) -> None: - assert dagbag.dags["ml_train"].timetable.summary == "None" + assert dagbag.dags["ml_train"].schedule is None def test_ml_score_runs_every_hour(dagbag: DagBag) -> None: - # `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend. - assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *" + # `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que la timetable le rend. + assert dagbag.dags["ml_score"].timetable.expression == "0 * * * *" def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None: # Le decalage n'est pas cosmetique : la regle `anomaly` compare une lecture a la `prediction` # du meme instant, que `ml_score` ecrit a l'heure pile. - assert dagbag.dags["alertes"].timetable.summary == "15 * * * *" + assert dagbag.dags["alertes"].timetable.expression == "15 * * * *" + + +def test_historical_import_has_no_schedule(dagbag: DagBag) -> None: + assert dagbag.dags["historical_import"].schedule is None + + +def test_mock_api_import_uses_an_explicit_hourly_interval(dagbag: DagBag) -> None: + timetable = dagbag.dags["mock_api_import"].timetable + + assert isinstance(timetable, CronTriggerTimetable) + assert timetable.serialize()["expression"] == "45 * * * *" + + manual_interval = timetable.infer_manual_data_interval( + run_after=datetime.fromisoformat("2026-09-22T12:30:00+00:00"), + ) + + assert manual_interval.end - manual_interval.start == timedelta(hours=1) def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None: @@ -67,12 +95,49 @@ def test_alertes_recommendation_task_calls_the_backend_cli(dagbag: DagBag) -> No assert "app.cli generate-recommendations" in tache.bash_command +def test_historical_import_calls_the_existing_backend_module(dagbag: DagBag) -> None: + tache = dagbag.dags["historical_import"].get_task("import_historical") + assert "app.etl.historical_import" in tache.bash_command + + +def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> None: + commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command + + assert "--csv /opt/data/raw/all_sites_combined.csv" in commande + assert "--metadata /opt/data/raw/dataset_metadata.json" in commande + + +def test_mock_api_import_calls_the_existing_backend_module(dagbag: DagBag) -> None: + commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command + + assert "app.etl.mock_api_import" in commande + + +def test_mock_api_import_uses_the_airflow_data_interval(dagbag: DagBag) -> None: + commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command + + assert "--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande + assert "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande + assert "--limit 1000" in commande + + @pytest.mark.parametrize("task_id", ["detection", "recommandations"]) def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None: # Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008). assert "/opt/backend" in dagbag.dags["alertes"].get_task(task_id).bash_command +def test_historical_import_runs_in_the_backend_environment(dagbag: DagBag) -> None: + commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command + assert "/opt/backend" in commande + + +def test_mock_api_import_runs_in_the_backend_environment(dagbag: DagBag) -> None: + commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command + + assert "/opt/backend" in commande + + def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None: # `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire # tant que la detection n'a pas ecrit. @@ -114,6 +179,14 @@ def duree_au_pire(tache: BaseOperator) -> timedelta: return (tache.retries + 1) * tache.execution_timeout + tache.retries * tache.retry_delay +def test_mock_api_import_worst_case_stays_below_its_hourly_step( + dagbag: DagBag, +) -> None: + tache = dagbag.dags["mock_api_import"].get_task("import_mock_api") + + assert duree_au_pire(tache) < timedelta(hours=1) + + def test_alertes_worst_case_stays_below_its_hourly_step(dagbag: DagBag) -> None: # Les deux taches s'enchainent : c'est leur somme, reprises comprises, qui doit tenir dans le # pas horaire, sinon `max_active_runs=1` fait attendre l'execution suivante. @@ -133,6 +206,27 @@ def test_alertes_retries_after_a_transient_failure(dagbag: DagBag, task_id: str) assert dagbag.dags["alertes"].get_task(task_id).retries >= 1 +def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) -> None: + assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1 + + +def test_mock_api_import_retries_after_a_transient_failure(dagbag: DagBag) -> None: + assert dagbag.dags["mock_api_import"].get_task("import_mock_api").retries >= 1 + + +def test_derive_runs_once_a_day(dagbag: DagBag) -> None: + assert dagbag.dags["derive"].timetable.expression == "30 5 * * *" + + +def test_derive_calls_the_backend_drift_module(dagbag: DagBag) -> None: + assert "app.monitoring.drift" in dagbag.dags["derive"].get_task("derive").bash_command + + +def test_derive_never_retries_a_detected_drift(dagbag: DagBag) -> None: + # Une derive n'est pas une panne passagere : la rejouer la redeclarerait a l'identique. + assert dagbag.dags["derive"].get_task("derive").retries == 0 + + @pytest.mark.parametrize(("dag_id", "task_id"), TACHES) def test_tasks_never_resync_the_baked_environment( dagbag: DagBag, dag_id: str, task_id: str diff --git a/etl/airflow/uv.lock b/etl/airflow/uv.lock index 0fd3a71..fbfc7c0 100644 --- a/etl/airflow/uv.lock +++ b/etl/airflow/uv.lock @@ -3,61 +3,12 @@ revision = 3 requires-python = "==3.12.*" [[package]] -name = "aiohappyeyeballs" -version = "2.7.1" +name = "a2wsgi" +version = "1.10.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/cb/822c56fbea97e9eee201a2e434a80437f6750ebcb1ed307ee3a0a7505b14/a2wsgi-1.10.10.tar.gz", hash = "sha256:a5bcffb52081ba39df0d5e9a884fc6f819d92e3a42389343ba77cbf809fe1f45", size = 18799, upload-time = "2025-06-18T09:00:10.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "typing-extensions" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, - { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, - { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, - { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, - { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, - { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, - { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, - { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, - { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, - { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, - { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, + { url = "https://files.pythonhosted.org/packages/02/d5/349aba3dc421e73cbd4958c0ce0a4f1aa3a738bc0d7de75d2f40ed43a535/a2wsgi-1.10.10-py3-none-any.whl", hash = "sha256:d2b21379479718539dc15fce53b876251a0efe7615352dfe49f6ad1bc507848d", size = 17389, upload-time = "2025-06-18T09:00:09.676Z" }, ] [[package]] @@ -69,6 +20,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/0a/b56ab8163d54960337fdca475d3dfd56c8badf6172e79cf2ad00d5335dc1/aiosmtplib-5.1.3-py3-none-any.whl", hash = "sha256:f7d76ce3d4995a65a178c1f11e1bd1607706b921d00cb768e7a2c7f7ef5517a8", size = 30116, upload-time = "2026-09-08T02:11:19.352Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, +] + [[package]] name = "alembic" version = "1.19.2" @@ -83,6 +46,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/cb/9014784dcb0585977ae23b6f43331d0a33c51ac0d692506d12b4f5ee9f3b/alembic-1.19.2-py3-none-any.whl", hash = "sha256:32d553dcd577e6fe5c3c63e91468526d35e4dcecafe865d7db4e9b328fa93cb2", size = 267399, upload-time = "2026-09-04T17:10:12.796Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.15.1" @@ -98,93 +79,104 @@ wheels = [ [[package]] name = "apache-airflow" -version = "2.10.4" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "apache-airflow-core" }, + { name = "apache-airflow-task-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9e/148e8d9211c57689af9a8aafa228ff4d9b23bf23c203e973cd2fed9f1353/apache_airflow-3.3.2.tar.gz", hash = "sha256:cdbe0dcd47ff2e9ab267fcc050bc6e4fa6e40fb86bf3671e05d9672206da59bb", size = 31774, upload-time = "2026-09-17T08:39:00.645Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0f/835c3e3d7a09ce377a1ebc475718f404a2f6c976cb2662f84c6fcb7aaa96/apache_airflow-3.3.2-py3-none-any.whl", hash = "sha256:c1fd40a00cfc50e7ef8d0aa07a1418b059f22db03ad25c2793bb030b176dc085", size = 13331, upload-time = "2026-09-17T08:38:26.148Z" }, +] + +[[package]] +name = "apache-airflow-core" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "a2wsgi" }, + { name = "aiosqlite" }, { name = "alembic" }, { name = "apache-airflow-providers-common-compat" }, { name = "apache-airflow-providers-common-io" }, { name = "apache-airflow-providers-common-sql" }, - { name = "apache-airflow-providers-fab" }, - { name = "apache-airflow-providers-ftp" }, - { name = "apache-airflow-providers-http" }, - { name = "apache-airflow-providers-imap" }, { name = "apache-airflow-providers-smtp" }, - { name = "apache-airflow-providers-sqlite" }, + { name = "apache-airflow-providers-standard" }, + { name = "apache-airflow-task-sdk" }, { name = "argcomplete" }, { name = "asgiref" }, { name = "attrs" }, - { name = "blinker" }, + { name = "cachetools" }, + { name = "cadwyn" }, { name = "colorlog" }, - { name = "configupdater" }, - { name = "connexion", extra = ["flask"] }, { name = "cron-descriptor" }, { name = "croniter" }, { name = "cryptography" }, { name = "deprecated" }, { name = "dill" }, - { name = "flask" }, - { name = "flask-caching" }, - { name = "flask-session" }, - { name = "flask-wtf" }, - { name = "fsspec" }, - { name = "google-re2" }, - { name = "gunicorn" }, + { name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] }, { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "isoduration" }, { name = "itsdangerous" }, { name = "jinja2" }, { name = "jsonschema" }, { name = "lazy-object-proxy" }, + { name = "libcst" }, { name = "linkify-it-py" }, { name = "lockfile" }, - { name = "markdown-it-py" }, - { name = "markupsafe" }, - { name = "marshmallow-oneofschema" }, - { name = "mdit-py-plugins" }, { name = "methodtools" }, + { name = "msgspec" }, + { name = "natsort" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-proto" }, { name = "packaging" }, { name = "pathspec" }, { name = "pendulum" }, { name = "pluggy" }, { name = "psutil" }, + { name = "pydantic" }, { name = "pygments" }, + { name = "pygtrie" }, { name = "pyjwt" }, { name = "python-daemon" }, { name = "python-dateutil" }, - { name = "python-nvd3" }, { name = "python-slugify" }, + { name = "pyyaml" }, { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "rfc3339-validator" }, { name = "rich" }, { name = "rich-argparse" }, { name = "setproctitle" }, - { name = "sqlalchemy" }, - { name = "sqlalchemy-jsonfield" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "starlette" }, + { name = "structlog" }, + { name = "svcs" }, { name = "tabulate" }, { name = "tenacity" }, { name = "termcolor" }, + { name = "typing-extensions" }, { name = "universal-pathlib" }, - { name = "werkzeug" }, + { name = "uuid6" }, + { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/44/149c93a77328e1554ef917eaf4f9724a1ba66aba83c0380c23977a6a31e7/apache_airflow-2.10.4.tar.gz", hash = "sha256:10ebf8b95c59ba229f06235665e92cc684577861cfd1e96bdbe3d3eb7cb5779a", size = 12490817, upload-time = "2024-12-16T10:10:56.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/47/bcf1e5ebc134c6a39f06fe00534a8981ea195e7191ea2d60dabb7af0428e/apache_airflow_core-3.3.2.tar.gz", hash = "sha256:53f38e7749a2689a1d67c6e661284ecddaa687c1f7f3cae2d76b2eca7758210f", size = 7442317, upload-time = "2026-09-17T08:39:34.987Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/10/ef074670d8dc2281c5604366062be4011c206442ac0e9f0dddbbc3251e04/apache_airflow-2.10.4-py3-none-any.whl", hash = "sha256:9470a26479034ddede69fca913d7f84a32dd883368861b9421e2a692c0fc5ef4", size = 13445691, upload-time = "2024-12-16T10:09:55.104Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/1ee2db2eeda6411ce2611e7ce800f57a4b9d22920f5c4ccaed908f7664a1/apache_airflow_core-3.3.2-py3-none-any.whl", hash = "sha256:99258922bccec15be4700ff89f3c942b8acd93407e263c287ce863e671bb3c50", size = 6921273, upload-time = "2026-09-17T08:38:58.61Z" }, ] [[package]] name = "apache-airflow-providers-common-compat" -version = "1.9.0" +version = "1.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apache-airflow" }, { name = "asgiref" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/8b/67b58258b2dd774277d0b8643ac3ea4ce61d591e84de75d98d9e44268450/apache_airflow_providers_common_compat-1.9.0.tar.gz", hash = "sha256:805e86ea89b1d14ee5d7035e1baeed5d15dbb183d66b230d8b024f20208d029b", size = 29405, upload-time = "2025-11-17T19:10:39.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/a8/e1d304e2d98d5df26ef2da35e1ebfb914630f29fdb5d05560c6c982bca04/apache_airflow_providers_common_compat-1.19.0.tar.gz", hash = "sha256:887d1f84907df0b01f5ce812bd8c84aeb196742abf7a5fbb78eb5ce60adcd2bd", size = 45512, upload-time = "2026-09-14T14:48:05.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/38/5d3cb1395f460e16796a9fabbdb1012004b3d9b059804100679d3f763ed0/apache_airflow_providers_common_compat-1.9.0-py3-none-any.whl", hash = "sha256:6fc5463e804a742f0e21441e9c2c2441d280af7b8e68094759f7f65b398319b3", size = 37485, upload-time = "2025-11-17T19:08:42.085Z" }, + { url = "https://files.pythonhosted.org/packages/db/2e/904ef0308548a4de139fecbd591456dddc2f33f5baa10faf053d34853360/apache_airflow_providers_common_compat-1.19.0-py3-none-any.whl", hash = "sha256:72f03811f5e7cd219939e1c0f2a33ac5bb8538898b0c61adaadc5c9e8acb8a91", size = 46113, upload-time = "2026-09-14T14:47:24.519Z" }, ] [[package]] @@ -215,67 +207,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/fb/356daf357f185160f2d14d7541d30537fb009891fca137734ba1be2a1cbb/apache_airflow_providers_common_sql-1.29.0-py3-none-any.whl", hash = "sha256:eee20ed7a3a209dc7965255b69ee8e74443f4dd7308c5803b23308e25fbb6481", size = 67181, upload-time = "2025-11-17T19:08:46.756Z" }, ] -[[package]] -name = "apache-airflow-providers-fab" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "apache-airflow" }, - { name = "apache-airflow-providers-common-compat" }, - { name = "flask" }, - { name = "flask-appbuilder" }, - { name = "flask-login" }, - { name = "google-re2" }, - { name = "jmespath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/5f/e3428cba776c3a55dfb365a6c8000c36d35665496cc6bd1fbfacef6ffb17/apache_airflow_providers_fab-1.5.3.tar.gz", hash = "sha256:bb4d879fb9bf9bca7c0f103e1dc9d1fa25efe02e2c4536f4d60c789786fb1f89", size = 62751, upload-time = "2025-02-08T12:14:03.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/96/bbc8b1c87e20488398b5630d7ddac13c078d032c25741495c0e6eec00425/apache_airflow_providers_fab-1.5.3-py3-none-any.whl", hash = "sha256:0b1352e16266f40aa1037af316fd3abcc3852ca49b033acac9f9cad60e5f9764", size = 98118, upload-time = "2025-02-08T12:14:01.093Z" }, -] - -[[package]] -name = "apache-airflow-providers-ftp" -version = "3.13.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "apache-airflow" }, - { name = "apache-airflow-providers-common-compat" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/f7/2aa860291e5d96f8b3c7fca33c18c60b9122fec74a874af25a73bcf3e855/apache_airflow_providers_ftp-3.13.3.tar.gz", hash = "sha256:c6da470a73f2e20ded4499f8877dd7c013273ec861f04d67723046cbc1d59dee", size = 68282, upload-time = "2025-11-17T19:10:57.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/f0/74e0c1be43386a39c947034867612b7c3d4782d596b119db8663c314fc35/apache_airflow_providers_ftp-3.13.3-py3-none-any.whl", hash = "sha256:e4a0746bb71b07b75434ab015ad0f63c9d54f03d35c7c25c80769af9038b41e2", size = 20233, upload-time = "2025-11-17T19:09:02.039Z" }, -] - -[[package]] -name = "apache-airflow-providers-http" -version = "5.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "apache-airflow" }, - { name = "apache-airflow-providers-common-compat" }, - { name = "asgiref" }, - { name = "requests" }, - { name = "requests-toolbelt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/6c/2dca0438b5b67a7cb2997cc1bba58e72436e40489a04c764d48ac2047572/apache_airflow_providers_http-5.5.0.tar.gz", hash = "sha256:2267871cd3a44f4c9f306dc59c07ebd9a7fcd0d722d5a570c1401b08dfe6c73c", size = 69546, upload-time = "2025-11-17T19:11:04.265Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/e3/8109bee416123252cd946b74e57a7c0f900d3030577035dcb6455e20d401/apache_airflow_providers_http-5.5.0-py3-none-any.whl", hash = "sha256:2be7e5cc8b12df5824ab150cab1b4fa871ffa6f8a11eafbc4f941b36446f1d32", size = 33683, upload-time = "2025-11-17T19:09:10.406Z" }, -] - -[[package]] -name = "apache-airflow-providers-imap" -version = "3.9.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "apache-airflow" }, - { name = "apache-airflow-providers-common-compat" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/68/147cf98a31beeda7f88d7c171272f0e9d0bfa165b6ffc98d84c305ed93eb/apache_airflow_providers_imap-3.9.4.tar.gz", hash = "sha256:d33f6a460a409ffd6d22825a556e471abdc7ecca2c6e08aa4964671286b27313", size = 25632, upload-time = "2025-11-17T19:11:05.321Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/36/c922bc78cf1d08e8ec9f64aa97a0a3b14df28282faf4ae466203c41b059f/apache_airflow_providers_imap-3.9.4-py3-none-any.whl", hash = "sha256:f4672aad3836044f7eb99b1b2d9f64b974448f9deb13d91727b024c7a2f61404", size = 18210, upload-time = "2025-11-17T19:09:12.231Z" }, -] - [[package]] name = "apache-airflow-providers-smtp" version = "2.3.2" @@ -291,33 +222,52 @@ wheels = [ ] [[package]] -name = "apache-airflow-providers-sqlite" -version = "4.1.3" +name = "apache-airflow-providers-standard" +version = "1.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apache-airflow" }, - { name = "apache-airflow-providers-common-sql" }, + { name = "apache-airflow-providers-common-compat" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/88/f8ade86e72a4785da880968ea5f082281a9a201c380e3ffea2adcefc84cf/apache_airflow_providers_sqlite-4.1.3.tar.gz", hash = "sha256:006dadd9c69d941b099a1a16fae723db48528401ad756fd50e956463257e66f0", size = 32573, upload-time = "2025-11-17T19:11:44.097Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/42/b52035b3d3d12b62e99946ed7576318b278b922c6233a5454673122ae7ad/apache_airflow_providers_standard-1.19.0.tar.gz", hash = "sha256:890c0d00b2d4cda304f57f32a56beef160b2a4da5c1f67e6f5baa974b514ed73", size = 225842, upload-time = "2026-09-14T14:48:24.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/27/2fa28f76d8f6b61e566c94cd17a3aa8a00b09a112e38cd78a8605b3afa9e/apache_airflow_providers_sqlite-4.1.3-py3-none-any.whl", hash = "sha256:f5baff823eec558e85ad91cfbab9fb48ac08cd60b08caca4132b28010bd46fe9", size = 11495, upload-time = "2025-11-17T19:09:54.154Z" }, + { url = "https://files.pythonhosted.org/packages/1a/18/72823dc179a3a4ac609a9555d2b98e6ef62941318fdafb64ca0ce14f592c/apache_airflow_providers_standard-1.19.0-py3-none-any.whl", hash = "sha256:d5b3d8877bb7d644d25578483f420ca04fbf76eef56bf86c655321f18a7c9325", size = 163032, upload-time = "2026-09-14T14:47:49.398Z" }, ] [[package]] -name = "apispec" -version = "6.10.0" +name = "apache-airflow-task-sdk" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "apache-airflow-core" }, + { name = "asgiref" }, + { name = "attrs" }, + { name = "babel" }, + { name = "colorlog" }, + { name = "fsspec" }, + { name = "greenback" }, + { name = "httpx" }, + { name = "isoduration" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "methodtools" }, + { name = "msgspec" }, + { name = "opentelemetry-api" }, { name = "packaging" }, + { name = "pathspec" }, + { name = "pendulum" }, + { name = "pluggy" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "pygtrie" }, + { name = "python-dateutil" }, + { name = "structlog" }, + { name = "tenacity" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/f1/1f5a9332df3ecd90cc5ab69bc58a4174b8ba2ac1720c4c26b01d20751bf5/apispec-6.10.0.tar.gz", hash = "sha256:0a888555cd4aa5fb7176041be15684154fd8961055e1672e703abf737e8761bf", size = 80631, upload-time = "2026-03-06T21:48:40.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/23/b80ddd45781d46c5924fceeb62dccffa8c7695b83ae98d5b26abad0dc442/apache_airflow_task_sdk-1.3.2.tar.gz", hash = "sha256:d9a1c7785684a215df3927b028a44b2b6927dacebd54c0d281b5906febc3efef", size = 1714211, upload-time = "2026-09-17T08:40:19.774Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/88/e149b20246c4689e7d27163e4e3bb8946ef31617cfb3b9c427813483fe5b/apispec-6.10.0-py3-none-any.whl", hash = "sha256:8ff23e0de9a0ceb62ff70047241126315bd17b8d0565a567934c0156f4ddbb43", size = 31313, upload-time = "2026-03-06T21:48:39.404Z" }, -] - -[package.optional-dependencies] -yaml = [ - { name = "pyyaml" }, + { url = "https://files.pythonhosted.org/packages/39/55/6739ddc778d08cae398c388e8ad72d2fae054a58dd32a7e0f75eac81a502/apache_airflow_task_sdk-1.3.2-py3-none-any.whl", hash = "sha256:5ff196842b7bc203e13332592ab78ed82cfa1225368a16bb91bf9d5705c62293", size = 607451, upload-time = "2026-09-17T08:39:56.324Z" }, ] [[package]] @@ -329,6 +279,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" }, ] +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + [[package]] name = "asgiref" version = "3.12.1" @@ -357,21 +320,29 @@ wheels = [ ] [[package]] -name = "blinker" -version = "1.9.0" +name = "cachetools" +version = "7.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/2c/3f18755527b03ca9ff6be724bd5370cb777c76a87f17301377cf04a4729b/cachetools-7.2.0.tar.gz", hash = "sha256:bcac1a1b8da6909994a2957238a57b8140dab7c5c5c69a43669654fe87a33c1d", size = 41129, upload-time = "2026-09-16T20:48:27.209Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bb/1c6e7a89b11da19f137e4ef5a5c5fe47a5bb686449ba343944ccf8fb34b2/cachetools-7.2.0-py3-none-any.whl", hash = "sha256:3045213f186b89fdd95d94441354c4bd87c570b7a38d8c3fd1d9dd37f6dc90d8", size = 16918, upload-time = "2026-09-16T20:48:25.575Z" }, ] [[package]] -name = "cachelib" -version = "0.17.0" +name = "cadwyn" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c6/f4/b20875916b83f68775093554ce2544b12255396ba69abd93d8903cce0feb/cachelib-0.17.0.tar.gz", hash = "sha256:f3c7dc8d3c1132ab699681ffdf8a52d341d9425ac1401c538cf0b1d87b1677c8", size = 135529, upload-time = "2026-08-24T00:40:51.851Z" } +dependencies = [ + { name = "fastapi" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/31/9a986a9fe20c6b52bde0dd23c9fc002388ab0c8f7b30a37217b07aa1875f/cadwyn-7.0.0.tar.gz", hash = "sha256:3b57549a37e218dffb55ac5d188639de0516207f05642b084f23627c5a44d614", size = 662353, upload-time = "2026-06-06T16:34:39.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/87/9110494f2816d3f2907ac9a0a0a5387f34bc4fa9755721ad09f0a2c99e9b/cachelib-0.17.0-py3-none-any.whl", hash = "sha256:f83909b6f78741c3a5d76d292d13bf24964ffb13e00ea1d18f92e20599766ce0", size = 28221, upload-time = "2026-08-24T00:40:50.237Z" }, + { url = "https://files.pythonhosted.org/packages/73/c4/efee5781dc8b3a50fd876d413cad6db6ee69e1f21d5a07ca469cec03cd22/cadwyn-7.0.0-py3-none-any.whl", hash = "sha256:727d3c444ae992bb2a238246d13f173e4802c92e1c901458975549e6f0522560", size = 61194, upload-time = "2026-06-06T16:34:37.768Z" }, ] [[package]] @@ -456,19 +427,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] -[[package]] -name = "clickclick" -version = "20.10.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/19/f91d85941b79964d569a3729bf9f8b7f85ab47240248e77b7c0c8ed6ecc3/clickclick-20.10.2.tar.gz", hash = "sha256:4efb13e62353e34c5eef7ed6582c4920b418d7dedc86d819e22ee089ba01802c", size = 9914, upload-time = "2020-10-03T13:36:47.966Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/7e/c08007d3fb2bbefb430437a3573373590abedc03566b785d7d6763b22480/clickclick-20.10.2-py2.py3-none-any.whl", hash = "sha256:c8f33e6d9ec83f68416dd2136a7950125bd256ec39ccc9a85c6e280a16be2bb5", size = 7368, upload-time = "2020-10-03T13:36:49.842Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -490,41 +448,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" }, ] -[[package]] -name = "configupdater" -version = "3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/603bd8a65e040b23d25b5843836297b0f4e430f509d8ed2ef8f072fb4127/ConfigUpdater-3.2.tar.gz", hash = "sha256:9fdac53831c1b062929bf398b649b87ca30e7f1a735f3fbf482072804106306b", size = 140603, upload-time = "2023-11-27T17:16:45.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/f0/b59cb7613d9d0f866b6ff247c5953ad78363c27ff5d684a2a98899ab8220/ConfigUpdater-3.2-py2.py3-none-any.whl", hash = "sha256:0f65a041627d7693840b4dd743581db4c441c97195298a29d075f91b79539df2", size = 34688, upload-time = "2023-11-27T17:16:43.53Z" }, -] - -[[package]] -name = "connexion" -version = "2.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "clickclick" }, - { name = "flask" }, - { name = "inflection" }, - { name = "itsdangerous" }, - { name = "jsonschema" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/8b/c1d8a2e9327787354e936184f424b1ae96e526a0dad031bbc218c9dcaf35/connexion-2.14.2.tar.gz", hash = "sha256:dbc06f52ebeebcf045c9904d570f24377e8bbd5a6521caef15a06f634cf85646", size = 82819, upload-time = "2023-01-25T10:05:14.261Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/e6/851b3d7688115b176eb5d3e45055d1dc5b2b91708007064a38b0e93813ed/connexion-2.14.2-py2.py3-none-any.whl", hash = "sha256:a73b96a0e07b16979a42cde7c7e26afe8548099e352cf350f80c57185e0e0b36", size = 95127, upload-time = "2023-01-25T10:05:12.06Z" }, -] - -[package.optional-dependencies] -flask = [ - { name = "flask" }, - { name = "itsdangerous" }, -] - [[package]] name = "cron-descriptor" version = "2.1.0" @@ -644,7 +567,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "apache-airflow", specifier = "==2.10.4" }] +requires-dist = [{ name = "apache-airflow", specifier = "==3.3.2" }] [package.metadata.requires-dev] dev = [ @@ -653,185 +576,50 @@ dev = [ ] [[package]] -name = "flask" -version = "2.2.5" +name = "fastapi" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "werkzeug" }, + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/76/a4d2c4436dda4b0a12c71e075c508ea7988a1066b06a575f6afe4fecc023/Flask-2.2.5.tar.gz", hash = "sha256:edee9b0a7ff26621bd5a8c10ff484ae28737a2410d99b0bb9a6850c7fb977aa0", size = 697814, upload-time = "2023-05-02T14:42:36.742Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/1a/8b6d48162861009d1e017a9740431c78d860809773b66cac220a11aa3310/Flask-2.2.5-py3-none-any.whl", hash = "sha256:58107ed83443e86067e41eff4631b058178191a355886f8e479e347fa1285fdf", size = 101817, upload-time = "2023-05-02T14:42:34.858Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] -[[package]] -name = "flask-appbuilder" -version = "4.5.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "apispec", extra = ["yaml"] }, - { name = "click" }, - { name = "colorama" }, +[package.optional-dependencies] +standard-no-fastapi-cloud-cli = [ { name = "email-validator" }, - { name = "flask" }, - { name = "flask-babel" }, - { name = "flask-jwt-extended" }, - { name = "flask-limiter" }, - { name = "flask-login" }, - { name = "flask-sqlalchemy" }, - { name = "flask-wtf" }, - { name = "jsonschema" }, - { name = "marshmallow" }, - { name = "marshmallow-sqlalchemy" }, - { name = "prison" }, - { name = "pyjwt" }, - { name = "python-dateutil" }, - { name = "sqlalchemy" }, - { name = "sqlalchemy-utils" }, - { name = "werkzeug" }, - { name = "wtforms" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d6/9c/b6920650c21879f1e27afafba57af2985120d6e7896b625e7f671abd1834/Flask-AppBuilder-4.5.3.tar.gz", hash = "sha256:2f3f953b8134bed02ed0236ab7e85e6c354b1b3680069d76dfadc017eb05c561", size = 7355555, upload-time = "2025-01-21T16:14:58.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/47/998e345adf9878ee74961709c5be8c2999054b11576373c6b80947e479f2/Flask_AppBuilder-4.5.3-py3-none-any.whl", hash = "sha256:9223db6c43939f8646fc6458d949ea4d5de182e8455bdfb0010bb37359d96ccf", size = 2231389, upload-time = "2025-01-21T16:14:43.292Z" }, -] - -[[package]] -name = "flask-babel" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "flask" }, + { name = "fastapi-cli", extra = ["standard-no-fastapi-cloud-cli"] }, + { name = "httpx" }, { name = "jinja2" }, - { name = "pytz" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d7/fe/655e6a5a99ceb815fe839f0698956a9d6c7d5bcc06ca1ee7c6eb6dac154b/Flask-Babel-2.0.0.tar.gz", hash = "sha256:f9faf45cdb2e1a32ea2ec14403587d4295108f35017a7821a2b1acb8cfd9257d", size = 19588, upload-time = "2020-08-27T03:14:13.932Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/3e/02331179ffab8b79e0383606a028b6a60fb1b4419b84935edd43223406a0/Flask_Babel-2.0.0-py3-none-any.whl", hash = "sha256:e6820a052a8d344e178cdd36dd4bb8aea09b4bda3d5f9fa9f008df2c7f2f5468", size = 9345, upload-time = "2020-08-27T03:14:12.746Z" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, ] [[package]] -name = "flask-caching" -version = "2.4.1" +name = "fastapi-cli" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cachelib" }, - { name = "flask" }, + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/15/d2852e86419c6c1416cba00c177b2cf609b5c2935372933684f84111c631/flask_caching-2.4.1.tar.gz", hash = "sha256:ecef4ca80b9cb1fa01d461373a0fce441527cd57eecee1aa71c1f6d750d7ff77", size = 165380, upload-time = "2026-07-08T19:23:57.264Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/eb/3b534c6f8e157f9ddbf2a153512307c886cad0b258739c200dd8ff8c4452/fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424", size = 26636, upload-time = "2026-07-16T12:16:58.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/e3/ad7572c7f00b1286f2fc2a387f01b62bb46b59c5f91536093eae57889adb/flask_caching-2.4.1-py3-none-any.whl", hash = "sha256:5f5555d610ec1f230c8200ae00c1c723ee562f657c22f896b806f4689513b952", size = 28977, upload-time = "2026-07-08T19:23:55.68Z" }, + { url = "https://files.pythonhosted.org/packages/d5/53/56ae5ae17bb0a5d89d1d31e5320eb1865553ebbfbde91cdc4c221245f2a8/fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4", size = 14670, upload-time = "2026-07-16T12:16:57.297Z" }, ] -[[package]] -name = "flask-jwt-extended" -version = "4.7.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "pyjwt" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/20/bf/75189cf38cd391dddeb097001be3bc9ec24a8cae5a5a3698cd0a3fcaa182/flask_jwt_extended-4.7.4.tar.gz", hash = "sha256:78fd0f460317facf3a0084a6457ffaf2f1dda9eefbd576f94cea35b0eadd5531", size = 34672, upload-time = "2026-05-13T15:23:17.664Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/38/547a19f8ed0460e8c67c5b9e56ad72002fb06a1862fb786ef071ff03b9df/flask_jwt_extended-4.7.4-py2.py3-none-any.whl", hash = "sha256:daad1981117f4972d63c363d013f290de307aad781a935921b603b714817393c", size = 22699, upload-time = "2026-05-13T15:23:16.503Z" }, -] - -[[package]] -name = "flask-limiter" -version = "3.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "limits" }, - { name = "ordered-set" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/75/92b237dd4f6e19196bc73007fff288ab1d4c64242603f3c401ff8fc58a42/flask_limiter-3.12.tar.gz", hash = "sha256:f9e3e3d0c4acd0d1ffbfa729e17198dd1042f4d23c130ae160044fc930e21300", size = 303162, upload-time = "2025-03-15T02:23:10.734Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/ba/40dafa278ee6a4300179d2bf59a1aa415165c26f74cfa17462132996186b/flask_limiter-3.12-py3-none-any.whl", hash = "sha256:b94c9e9584df98209542686947cf647f1ede35ed7e4ab564934a2bb9ed46b143", size = 28490, upload-time = "2025-03-15T02:23:08.919Z" }, -] - -[[package]] -name = "flask-login" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" }, -] - -[[package]] -name = "flask-session" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cachelib" }, - { name = "flask" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/bf/b8b3e20cf03d3938ef7e94970e43491a49386c65e07aca7e6a4e583be28f/Flask-Session-0.5.0.tar.gz", hash = "sha256:190875e6aebf2953c6803d42379ef3b934bc209ef8ef006f97aecb08f5aaeb86", size = 11319, upload-time = "2023-05-11T18:43:16.041Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/d4/b374183251054928ddb5e72f9a0d3d764d0f2af0638fbbdf205df26e55e3/flask_session-0.5.0-py3-none-any.whl", hash = "sha256:1619bcbc16f04f64e90f8e0b17145ba5c9700090bb1294e889956c1282d58631", size = 7182, upload-time = "2023-05-11T18:43:14.143Z" }, -] - -[[package]] -name = "flask-sqlalchemy" -version = "2.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/35/f0/39dd2d8e7e5223f78a5206d7020dc0e16718a964acfb3564d89e9798ab9b/Flask-SQLAlchemy-2.5.1.tar.gz", hash = "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912", size = 132750, upload-time = "2021-03-18T19:03:02.733Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/2c/9088b6bd95bca539230bbe9ad446737ed391aab9a83aff403e18dded3e75/Flask_SQLAlchemy-2.5.1-py2.py3-none-any.whl", hash = "sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390", size = 17716, upload-time = "2021-03-18T19:03:00.702Z" }, -] - -[[package]] -name = "flask-wtf" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "itsdangerous" }, - { name = "wtforms" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/f1/605a56d4ea217b307f3e6f4d663e0351253d85d841edc93ba559f0648e19/flask_wtf-1.3.0.tar.gz", hash = "sha256:61d5dabc50c3df885c297dcbd80810443a5d632106c8a69cab8ce740f0cdd7cc", size = 50414, upload-time = "2026-04-23T07:41:55.096Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/d2/97adf2ec7af95522573e6dd5493ee84792d0fbfb2def010c4a581b8d6e5e/flask_wtf-1.3.0-py3-none-any.whl", hash = "sha256:dc5e3a4ce97f75c47bf6c1c72ad2c3b7bdf579a2ed13aebcc5d3d81fe2571160", size = 13959, upload-time = "2026-04-23T07:41:53.828Z" }, -] - -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +[package.optional-dependencies] +standard-no-fastapi-cloud-cli = [ + { name = "uvicorn", extra = ["standard"] }, ] [[package]] @@ -843,25 +631,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f", size = 221738, upload-time = "2026-09-18T17:50:41.341Z" }, ] -[[package]] -name = "google-re2" -version = "1.1.20251105" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/60/805c654ba53d685513df955ee745f71920fe8e6a284faf0f9b9dc19b659c/google_re2-1.1.20251105.tar.gz", hash = "sha256:1db14a292ee8303b91e91e7c37e05ac17d3c467f29416c79ac70a78be3e65bda", size = 11676, upload-time = "2025-11-05T14:58:07.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/20/73b487538e9107c2fd96aed737e3f3890dfce3e292622e4ffb2f9c810ee5/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b30f09b4d63249c72e65ccae4cbf6b331b48c22fc7cb439f1d85f347b9d07ceb", size = 485591, upload-time = "2025-11-05T14:57:20.961Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9a/ca3a993bdb5dc6d5b2616b9657b2872a83d1827f8bd3ab50cd629eb751c7/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:9a77892c524b8bdf3d47d7cad1cc2ac3a0108bdd65007ef4c02888fa46baf8ee", size = 518780, upload-time = "2025-11-05T14:57:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/df/37/b2e367987371514253ec9e514637f457deaacb7acc1c900814f3a6421e0f/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a3ac51b28cbf25c100dfd8849212d878d7005d1d4a7e129a10789043c56b6021", size = 486966, upload-time = "2025-11-05T14:57:24.575Z" }, - { url = "https://files.pythonhosted.org/packages/d9/69/1db6742943c0ac254bfb7d8a37a5d3f73f016a65cfa1f84fe3a0451820f6/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:9f7158afc9825ac2654c6561aea94a1f7edb5b5b88e6e3639bb80bb817d102ac", size = 520225, upload-time = "2025-11-05T14:57:26.039Z" }, - { url = "https://files.pythonhosted.org/packages/f4/0a/0747c92dbebe2c09a26bd7386d372b5c5a9926236b4f3d69bb8f15db05cb/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:5320da07dc3b7ac7f407514f42ac17d67e771ac7c7562d449571185e6fb601b2", size = 482943, upload-time = "2025-11-05T14:57:27.353Z" }, - { url = "https://files.pythonhosted.org/packages/7f/14/6bfc6838bb6cb561824ac03deeab2bd11d5d9a93505f536c8fa2f6bd46c4/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:5a4e5785bc30d52ce655d805b07ad2d8a4905429a5f690ae9c2f1caa76665709", size = 510384, upload-time = "2025-11-05T14:57:29.139Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0a/6add090c917ee39f6f0be753037cafceb3bad904b424efc155fb38082635/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b7a3b90f747130310d4b3b8e19ebb845d0d97c1deb63b36f76c7242dacbd736", size = 572446, upload-time = "2025-11-05T14:57:30.495Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1c/8b1ccbeade96a21435d55b5185cd6d9b2ceab5a9af998a4d9099e0540759/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:809c5fa5d08279413b29c2e2c5c528e85cd94a0e0fd897db595a0c09eeee2782", size = 591348, upload-time = "2025-11-05T14:57:31.808Z" }, - { url = "https://files.pythonhosted.org/packages/62/cf/7bdd7a1ae7828b613011da808eafec4da3132f43c3be6af5e0bd670ebe8b/google_re2-1.1.20251105-1-cp312-cp312-win32.whl", hash = "sha256:d8424e63a9ec0fe5bde03d97876b2431f8a746af33eb475fa1ae39144bd05b2a", size = 433787, upload-time = "2025-11-05T14:57:33.071Z" }, - { url = "https://files.pythonhosted.org/packages/31/e9/5dd951c35acaabfe87c67228b9af2cdcd7779d9167edbe6b9094b8a8e529/google_re2-1.1.20251105-1-cp312-cp312-win_amd64.whl", hash = "sha256:062313c309f93dfeb6966372f4c446580e98879133ec155522eea8aaf568a5cd", size = 491726, upload-time = "2025-11-05T14:57:34.39Z" }, - { url = "https://files.pythonhosted.org/packages/60/8d/c1afd29fc2cb475fd4c634f3d3c8099c0efb662362c10b27a9eaf11c9357/google_re2-1.1.20251105-1-cp312-cp312-win_arm64.whl", hash = "sha256:558f144b26a9555ae4e9467cc3aa3299a8ce13217f328b21ae326ca0633be19b", size = 642673, upload-time = "2025-11-05T14:57:35.693Z" }, -] - [[package]] name = "googleapis-common-protos" version = "1.75.3" @@ -874,6 +643,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/7a/7d79170c6ce6f12e109df2b3879d6b934010cf4f99aea8de8b7e5408c174/googleapis_common_protos-1.75.3-py3-none-any.whl", hash = "sha256:a018d2bf098ca9fb6faa08d5bb780e2a2c2f73c566f069761331386c9596d3f2", size = 306984, upload-time = "2026-09-03T22:30:45.133Z" }, ] +[[package]] +name = "greenback" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "outcome" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/d2/3b70d0f03a1e0f48d4f2348de435fa282e5530ae60812fef672cabc40a28/greenback-1.3.0.tar.gz", hash = "sha256:d1441f542ec9c6efb32a9250dd954a5b1cc1eb789294c19b1eb747f49cab818c", size = 8070613, upload-time = "2025-12-23T01:49:33.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/41/a1b338d80775c47f79cd7310d57ad4b98730f0656b15464a57dab821c5bb/greenback-1.3.0-py3-none-any.whl", hash = "sha256:b0a333a35b40f422981ebdeefc7e0a00568f2ac634604d0108cc8c30da9b6252", size = 29079, upload-time = "2025-12-23T01:49:31.81Z" }, +] + [[package]] name = "greenlet" version = "3.5.6" @@ -883,7 +666,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, @@ -911,15 +696,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/2c/309268b7b39f6deb2342f634841e105623a0b67982e8b10ec516782ff1c6/grpcio-1.84.0-cp312-cp312-win_amd64.whl", hash = "sha256:49717e857899f4136d7657bf5aded61ac479110a075438290923a4d86af7cd02", size = 5253534, upload-time = "2026-09-14T06:58:03.336Z" }, ] -[[package]] -name = "gunicorn" -version = "26.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -942,6 +718,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -967,12 +758,15 @@ wheels = [ ] [[package]] -name = "inflection" -version = "0.5.1" +name = "importlib-metadata" +version = "9.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, + { url = "https://files.pythonhosted.org/packages/b3/55/ecca97ae19075f1fac62def77731e7f535e6c1fb8f92ff08160c5e6dade8/importlib_metadata-9.0.1-py3-none-any.whl", hash = "sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0", size = 27920, upload-time = "2026-08-28T15:30:33.433Z" }, ] [[package]] @@ -984,6 +778,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -1005,15 +811,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jmespath" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -1056,17 +853,21 @@ wheels = [ ] [[package]] -name = "limits" -version = "5.8.0" +name = "libcst" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "deprecated" }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/c0/098e5c91ff1537f00c85a6438b6cb1863d17144680cc91f47c87f104a200/libcst-1.9.0.tar.gz", hash = "sha256:087b58a9afe076bb08e2d726478e1f16cb928d67ffa9092817e033c335de522a", size = 914739, upload-time = "2026-07-29T21:28:43.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bb/d22c37c33dfe18084634f5ef89f8f0749ffe7b6e0ad312722aafd86bbbdb/libcst-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd1a3500c41784075c4946a995d5ad89f68fa0d226b63ff3c4d78f6ea6dd23e5", size = 2043159, upload-time = "2026-07-29T19:24:49.013Z" }, + { url = "https://files.pythonhosted.org/packages/10/b8/2dedef84d72e7271119217503b69ed6dc5d0b2077685e163caae669d9c70/libcst-1.9.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:611cebd3bbc2014576f4dcc7b845b3c594c96ddc287a3db9b78f22eff156a7d3", size = 2203245, upload-time = "2026-07-29T19:24:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/e02ac2dad647423f947bb11f8322bfdba8ccfdd380e6c7b695add2d1acd4/libcst-1.9.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8d731abe1307720ea1a52d447555e8443a6d130e0e520243c0634a58f6edbc9d", size = 2255388, upload-time = "2026-07-29T19:24:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/6089a51518cfd2ff40950eb26bcc36951d7b6d4f4213568aa0290265aff7/libcst-1.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5351d92ca6ac1cc32e097700e1161ad1ceaa4d9b2cca5abadb1e94576b325", size = 2268982, upload-time = "2026-07-29T19:24:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/ed/78/26881ec466fb70cbc129dca26ccb5a52a0061face2c5822e4b61f00f9699/libcst-1.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03165a264653bb77f6a11b412ae09c08bdb0c25864f3b8d42b816ac64b9d4b9e", size = 2378174, upload-time = "2026-07-29T19:24:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7a/a4dba5f11faf12a12ffba06d18019987851aab33b590242a602ffd4fb1bd/libcst-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:b755ed4a4bc2faee849b54820023137d60d4c199e0a0822f0ff0c1bc49e49b48", size = 2103462, upload-time = "2026-07-29T19:24:56.966Z" }, + { url = "https://files.pythonhosted.org/packages/f5/13/57cb129093e0d6744b3c914ba6cbbdf51ed1b2c823882936c553a3a0cf1b/libcst-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e50576bad7d56459d9792cd0b0dfe5469dad13646e9a9c0a8b1ba20b269f332", size = 1979825, upload-time = "2026-07-29T19:24:58.403Z" }, ] [[package]] @@ -1130,56 +931,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, ] -[[package]] -name = "marshmallow" -version = "3.26.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, -] - -[[package]] -name = "marshmallow-oneofschema" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "marshmallow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/42/a0e00dea6a831acfe9d3fe664d695b7cefc02c27dd69d9ccb4bdc3c3d1a7/marshmallow_oneofschema-3.2.0.tar.gz", hash = "sha256:c06c8d9f14d51ffff152d66d85bd5f27d55cff10752a3b1f8c1f948bf5f597a0", size = 9096, upload-time = "2025-05-08T13:49:34.798Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/15/52d6ac14dcfe381e4f1c204c9c287623b8b462bc27c6cc468dba0560ed4c/marshmallow_oneofschema-3.2.0-py3-none-any.whl", hash = "sha256:19c87e6124ef05e2831e5c631168c909a50a8fe399921b9841b75fef3785be8c", size = 5898, upload-time = "2025-05-08T13:49:33.26Z" }, -] - -[[package]] -name = "marshmallow-sqlalchemy" -version = "0.28.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "marshmallow" }, - { name = "packaging" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/84/9cced63c2e1bbd4f243f5aed0a4eaf018ef97475e4eecf388bed4d5033b8/marshmallow-sqlalchemy-0.28.2.tar.gz", hash = "sha256:2ab0f1280c793e5aec81deab3e63ec23688ddfe05e5f38ac960368a1079520a1", size = 52156, upload-time = "2023-02-23T22:39:08.931Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/15/0c63bbbd7c21e44065ce7e198c0e515a98d2e37e5f5694d69595285dd67f/marshmallow_sqlalchemy-0.28.2-py2.py3-none-any.whl", hash = "sha256:c31b3bdf794de1d78c53e1c495502cbb3eeb06ed216869980c71d6159e7e9e66", size = 16095, upload-time = "2023-02-23T22:39:06.198Z" }, -] - -[[package]] -name = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1211,32 +962,28 @@ wheels = [ ] [[package]] -name = "multidict" -version = "6.9.0" +name = "msgspec" +version = "0.21.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/59/84b6cad9ddfdd9471db727b0e987c60ecbdb6b206ba265e8c50e74a1ab80/multidict-6.9.0.tar.gz", hash = "sha256:d7d32c0543494efbc9394e2b571725071d08e295993486bc9a43f6f89375ee01", size = 173221, upload-time = "2026-09-18T12:50:55.299Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/c4/9cbd1370a191a49a2c4d0217c19e391aff080a9b35222f10c71ec73fc0e9/multidict-6.9.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fdd484b84d3394e805689c56be3ab1f877ae7ff0eb9ff90a3ee7a755cbebab4f", size = 94050, upload-time = "2026-09-18T12:46:43.407Z" }, - { url = "https://files.pythonhosted.org/packages/cc/16/898930380953e7fcc66aabb9f822c1f46c4d459cdc0d82bed0de2f687628/multidict-6.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f93c9058a0eceac0df2ce9d4c8823b84786ee598194753c2ca0c100224405e47", size = 58006, upload-time = "2026-09-18T12:46:44.916Z" }, - { url = "https://files.pythonhosted.org/packages/cf/22/0a55faaa9bba51bd7a01040106a5d40292e87bea575a2905afc37256e684/multidict-6.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7ab60b91e11b25e7682c5cd8763fdd17929ea83f234ba441091f1492e631ea3", size = 55015, upload-time = "2026-09-18T12:46:46.304Z" }, - { url = "https://files.pythonhosted.org/packages/c0/e7/6dba7bcfda65432a5af6f5978fb84ffda0360471f9571ea434afb06bd09a/multidict-6.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:640113258c5925a9eed2c12523410b25565ac5df2fa6735fbae88fb09bcdd212", size = 313468, upload-time = "2026-09-18T12:46:47.587Z" }, - { url = "https://files.pythonhosted.org/packages/20/b7/3317f5a0aba7a38f74a250a872c5a7ba58407f71028e09a9961fb3804ac2/multidict-6.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ea72901860ccbe94517421681c60b13533ce03ba2f7bd96293c3a4d16ac4ccb", size = 313112, upload-time = "2026-09-18T12:46:49.149Z" }, - { url = "https://files.pythonhosted.org/packages/57/d3/46a482725368f3f29558bfca1a12a05ea68db6a51be54799d80b1bf81626/multidict-6.9.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:408fac672931f2458be3bc8c89d9facd16dac2aad17c7cee2ca1693eee99f07e", size = 298902, upload-time = "2026-09-18T12:46:50.721Z" }, - { url = "https://files.pythonhosted.org/packages/2f/71/ce4e30a3387b403bee1623873636e2b84f2e8b686a8d584f8eb9c62fa209/multidict-6.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:67bed23e9803945b0760650ec3e903af772c49abe869f3bc03c66b2e7649d5ef", size = 321654, upload-time = "2026-09-18T12:46:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/de/a4/3ad5099a178cbbd28913cd1fe77fd724cc576839f7893c301428d1d4f798/multidict-6.9.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb769ccc72e15d7d441e1a08f169d418376be77cdc387e813129c26b357fe50", size = 325316, upload-time = "2026-09-18T12:46:53.732Z" }, - { url = "https://files.pythonhosted.org/packages/47/cd/f27bf3242c2d70046211788d897679686a498bb032fbbc25f3c4d41ae9a5/multidict-6.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a5111a2bd824c821a3dd09da29680391b0caaa18fea7761358f4001e6898d1c", size = 317426, upload-time = "2026-09-18T12:46:55.155Z" }, - { url = "https://files.pythonhosted.org/packages/32/0c/8215c0167863a262ba8f886288c48fa2475c28fd0bc39fe548a8c86c8f73/multidict-6.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e991677c4bdc5d9f2e71c74717a4e32cfe98930ca05becdf722b5eae1329d6a", size = 278390, upload-time = "2026-09-18T12:46:56.749Z" }, - { url = "https://files.pythonhosted.org/packages/03/23/c51276d1086756f45cfa72d07f9d7bd21a59ce49b4ee7a245f99b2c9e527/multidict-6.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb69b724c345420ba49187a17a894f146099f5b2e501df42ddb4452ac8be37fa", size = 304354, upload-time = "2026-09-18T12:46:58.217Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/7d04a6347d7b06bf1868a995f91b2f2dc168a265860c55874101e4be7afe/multidict-6.9.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cff3cff5a725bdb8359962de8d7429aea592d7693dbd197eeabbdfab9b6300e9", size = 303669, upload-time = "2026-09-18T12:46:59.791Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/402d6b85d0e4aab551e700ca9123ebe2a52217836359eefbc1d92fd7f64d/multidict-6.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fca5b74b5909c29041f857c40d51d9273636fb4221cf020e4c452deb1c448a40", size = 316363, upload-time = "2026-09-18T12:47:01.41Z" }, - { url = "https://files.pythonhosted.org/packages/64/f6/49b70d1f876d7bc9263c972d2ecc58262590ea0c70743082ef091585b20d/multidict-6.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11e32ccf23cdbfcf8299a6a825930a858ec9a6aa05d6752d0f90f2bdf19489e1", size = 315126, upload-time = "2026-09-18T12:47:02.889Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5c/afff3cb22ccb3221b9715637f5e39f6a576a4c28ce8fc405555d94a17975/multidict-6.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ec1e387b1f8a85ae5b94aa8c4e0576912ffa4d31bd0578f24c950d4f05ee476", size = 278125, upload-time = "2026-09-18T12:47:04.539Z" }, - { url = "https://files.pythonhosted.org/packages/24/75/edd123ad77ac77a108b2f7ba322727e92c2d39d714df2bbca9c09af9d741/multidict-6.9.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4826f6b56456fb1e98111d7bc20cbdc7fa0a41b1f9ad80ff2dd3f2f5b226fe1", size = 315167, upload-time = "2026-09-18T12:47:06.897Z" }, - { url = "https://files.pythonhosted.org/packages/79/32/0ba8f8b6a0529bc46c5d4376128a3b9dbc3d7610d0a5768be0e599b5e56d/multidict-6.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e71a072c52c78b7f97cd4611df6cef10977e4f2367cd0654a7626192f931adde", size = 313411, upload-time = "2026-09-18T12:47:08.335Z" }, - { url = "https://files.pythonhosted.org/packages/a1/77/3b7b32a4331e99e89dc915c2dc1b054e49dd0f8c9c0c783c4d6063048da5/multidict-6.9.0-cp312-cp312-win32.whl", hash = "sha256:95d339c3b75b4a50c665bdcf8417428cd71c3e5cd48e194cb1d336fcb856beac", size = 50943, upload-time = "2026-09-18T12:47:10.105Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/484132b6c9d175939d8fb8b6c45b1d4eacecc79add962e1b4157ba138f60/multidict-6.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f06c4da5315a6f709b13408c3e13f3b475f8c559ec7608c3c67062512871235", size = 57304, upload-time = "2026-09-18T12:47:11.993Z" }, - { url = "https://files.pythonhosted.org/packages/27/14/63792486623819be73ce2149749bcc0b82772d2de22e02d452213603c432/multidict-6.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:0db5bf96ec2ce45a8bc7fbbe8a486089969bb2791a66b6789ee3aed0d5dd562e", size = 53498, upload-time = "2026-09-18T12:47:14.294Z" }, - { url = "https://files.pythonhosted.org/packages/75/2a/557689d56936a83c112ee28f4c4d7698ff01c24e22d217ad8fcce19982c7/multidict-6.9.0-py3-none-any.whl", hash = "sha256:57c2445049f7d8e66306f712868219da7ff7168ef42263dc032401211bf1205c", size = 19175, upload-time = "2026-09-18T12:50:52.172Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, +] + +[[package]] +name = "natsort" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, ] [[package]] @@ -1352,12 +1099,15 @@ wheels = [ ] [[package]] -name = "ordered-set" -version = "4.1.0" +name = "outcome" +version = "1.3.0.post0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, ] [[package]] @@ -1419,44 +1169,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "prison" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/65/4456caa4e9bbd1d4d4b5eecaea41bb2cd31efe0e7e423c7a9ad8e2be75ea/prison-0.2.1.tar.gz", hash = "sha256:e6cd724044afcb1a8a69340cad2f1e3151a5839fd3a8027fd1357571e797c599", size = 12040, upload-time = "2021-08-26T18:58:48.128Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/bd/e55e14cd213174100be0353824f2add41e8996c6f32081888897e8ec48b5/prison-0.2.1-py2.py3-none-any.whl", hash = "sha256:f90bab63fca497aa0819a852f64fb21a4e181ed9f6114deaa5dc04001a7555c5", size = 5794, upload-time = "2021-08-26T18:58:46.254Z" }, -] - -[[package]] -name = "propcache" -version = "0.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" }, - { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" }, - { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" }, - { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" }, - { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" }, - { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" }, - { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" }, - { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" }, - { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" }, - { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" }, - { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" }, - { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" }, - { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" }, - { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, -] - [[package]] name = "protobuf" version = "7.36.2" @@ -1497,6 +1209,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -1506,6 +1290,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pygtrie" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/06/31cf0821ada10cb65be108388ddf08d6c6990af90369db26b86776c3e86c/pygtrie-2.6.2.tar.gz", hash = "sha256:879264597743ef52bf80f2dff085364088c0848ae22544db95dad2bf52e06471", size = 60620, upload-time = "2026-09-14T15:02:38.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/98/c3e488257ffe7f9caa90c56f0e79e6a2860cf5af763da7b4e7eadc008228/pygtrie-2.6.2-py3-none-any.whl", hash = "sha256:51dcc50ecb8291238e261b4e9f8ecca7313b763da258dd320d208ca2bc86b5f4", size = 33434, upload-time = "2026-09-14T15:02:37.047Z" }, +] + [[package]] name = "pyjwt" version = "2.14.0" @@ -1556,14 +1349,22 @@ wheels = [ ] [[package]] -name = "python-nvd3" -version = "0.16.0" +name = "python-dotenv" +version = "1.2.3" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, - { name = "python-slugify" }, +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/e7/2a0bf4d9209d23a9121ab3f84e2689695d1ceba417f279f480af2948abef/python-nvd3-0.16.0.tar.gz", hash = "sha256:0115887289b3f751716ddd05c7b53ac5f05e71201e52496decdac453a50dcf7e", size = 34060, upload-time = "2024-04-22T07:55:15.856Z" } [[package]] name = "python-slugify" @@ -1577,15 +1378,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/f1/e450f6f6eb1217a6dc5f015589a21566839297920304961a7c2230477837/python_slugify-9.1.0-py3-none-any.whl", hash = "sha256:ab2d1d0e7ad5fa46b3756f0f3fcfb37d6d5baa006ccd1f22c2481a6ab53b8c12", size = 15641, upload-time = "2026-09-18T22:30:41.612Z" }, ] -[[package]] -name = "pytz" -version = "2026.3.post1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -1633,30 +1425,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, -] - -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, -] - [[package]] name = "rich" version = "13.9.4" @@ -1682,6 +1450,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/35/1cceccc5fcb50fa2ed53e2aa278cd032f3902682a73e763fb1ac3be8e6fa/rich_argparse-1.8.0-py3-none-any.whl", hash = "sha256:d2a3ce7854654e2253c578763ab0a32f05016f23a55fadba7b9a91b6c0e92142", size = 25616, upload-time = "2026-05-01T15:18:42.395Z" }, ] +[[package]] +name = "rich-toolkit" +version = "0.20.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/1c/f134352beb393cc17e6241ecf0bf4dd41a6759e2e3971a69a6ad185b87a2/rich_toolkit-0.20.5.tar.gz", hash = "sha256:0c9e1c414ffb0720be26285d472e263d1e704b71d31a7e13274b9996db4969e1", size = 213207, upload-time = "2026-09-08T16:13:12.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/5f/ee8c39750ed9837f54021a8a0eb96f971cd788c1a7d40931867d6d0518bd/rich_toolkit-0.20.5-py3-none-any.whl", hash = "sha256:e21fd616db9c0539d3c50f3433fb1173db95c19d3cbf215615e224182fad6c18", size = 39370, upload-time = "2026-09-08T16:13:10.885Z" }, +] + [[package]] name = "rpds-py" version = "2026.6.3" @@ -1748,6 +1530,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1757,44 +1548,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sqlalchemy" -version = "1.4.54" +version = "2.0.54" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/af/20290b55d469e873cba9d41c0206ab5461ff49d759989b3fe65010f9d265/sqlalchemy-1.4.54.tar.gz", hash = "sha256:4470fbed088c35dc20b78a39aaf4ae54fe81790c783b3264872a0224f437c31a", size = 8470350, upload-time = "2024-09-05T15:54:10.398Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/271aa905cf2964f841371a97f3e63ab692bf51b4423d0491e67bc7f64037/sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b", size = 9969559, upload-time = "2026-09-15T21:06:57.337Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/1b/aa9b99be95d1615f058b5827447c18505b7b3f1dfcbd6ce1b331c2107152/SQLAlchemy-1.4.54-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3f01c2629a7d6b30d8afe0326b8c649b74825a0e1ebdcb01e8ffd1c920deb07d", size = 1589983, upload-time = "2024-09-05T17:39:02.132Z" }, - { url = "https://files.pythonhosted.org/packages/59/47/cb0fc64e5344f0a3d02216796c342525ab283f8f052d1c31a1d487d08aa0/SQLAlchemy-1.4.54-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c24dd161c06992ed16c5e528a75878edbaeced5660c3db88c820f1f0d3fe1f4", size = 1630158, upload-time = "2024-09-05T17:50:13.255Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8b/f45dd378f6c97e8ff9332ff3d03ecb0b8c491be5bb7a698783b5a2f358ec/SQLAlchemy-1.4.54-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5e0d47d619c739bdc636bbe007da4519fc953393304a5943e0b5aec96c9877c", size = 1629232, upload-time = "2024-09-05T17:48:15.514Z" }, - { url = "https://files.pythonhosted.org/packages/0d/3c/884fe389f5bec86a310b81e79abaa1e26e5d78dc10a84d544a6822833e47/SQLAlchemy-1.4.54-cp312-cp312-win32.whl", hash = "sha256:12bc0141b245918b80d9d17eca94663dbd3f5266ac77a0be60750f36102bbb0f", size = 1592027, upload-time = "2024-09-05T17:54:02.253Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/c690d037be57efd3a69cde16a2ef1bd2a905dafe869434d33836de0983d0/SQLAlchemy-1.4.54-cp312-cp312-win_amd64.whl", hash = "sha256:f941aaf15f47f316123e1933f9ea91a6efda73a161a6ab6046d1cde37be62c88", size = 1593827, upload-time = "2024-09-05T17:52:07.454Z" }, + { url = "https://files.pythonhosted.org/packages/c6/30/75504fd1d70458000e85a3e772333dd0fbf80254b0dc4d41c99922e4d112/sqlalchemy-2.0.54-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffba7eb2d67c7505e82a0902aa854d8824b74c28a183820d6a8bd3cfd0f812c2", size = 2187596, upload-time = "2026-09-15T22:32:37.031Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3d/5dfbb9528a391186a99986daecc5cbe003f34408dee91db8f5cdcf917040/sqlalchemy-2.0.54-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63cae7210fea9899e0bf35c1f1ae55d3ddd9c6d47cae8b6b43d945afa79dd65b", size = 3448745, upload-time = "2026-09-15T22:40:18.168Z" }, + { url = "https://files.pythonhosted.org/packages/d9/93/34fdc4a4faced77037a6b3ba1db1acd92e9bd12c21e229b327edd1d3e881/sqlalchemy-2.0.54-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68d994e9b0d0423a02a20039631fa6fcbb7fa829a992f7605025774940305d19", size = 3466644, upload-time = "2026-09-15T22:35:37.7Z" }, + { url = "https://files.pythonhosted.org/packages/66/68/4beab40ae60ac3d679dbbb46bc0d2bb277013264a0bde37ecaf4b6780e98/sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3de32cc6721eb42c3aad35bcfb244bb7a18f66c00f3582aae6281d6287a339b5", size = 3401519, upload-time = "2026-09-15T22:40:19.907Z" }, + { url = "https://files.pythonhosted.org/packages/e5/df/a24757e3249b1c7c1c5f0317666d16a8ab901ba239819ecef29eb4ed172e/sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d31a2bc06a854ee52dd86b455be4df7c750b28817e2d1b884e31fff126c4fd7b", size = 3432590, upload-time = "2026-09-15T22:35:39.434Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0c/69559e3d90d200fbfe9c44aff39a9ff4e506b1a3e9557a203d69df721244/sqlalchemy-2.0.54-cp312-cp312-win32.whl", hash = "sha256:32de6deded25e8b9b11d07428d496ff24dfbc882b8e990c177266948cb5f3d9e", size = 2140825, upload-time = "2026-09-15T21:25:22.444Z" }, + { url = "https://files.pythonhosted.org/packages/d9/10/4a0f7113664c2877906db143709c52ddbd28a88e4d51f0b520bc30048ba7/sqlalchemy-2.0.54-cp312-cp312-win_amd64.whl", hash = "sha256:d65f8ca742ef1e1e14bc417ef59dc2ddf207a7b66b30cfdc6152447314e030cf", size = 2170005, upload-time = "2026-09-15T21:25:23.885Z" }, + { url = "https://files.pythonhosted.org/packages/24/a1/bd5e3e99bc9c8863b51ac5b9b03008a7f2da8c6b59695992f5c654e1265b/sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2", size = 1958015, upload-time = "2026-09-15T22:24:22.95Z" }, ] -[[package]] -name = "sqlalchemy-jsonfield" -version = "1.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/15/9555c858379eaa76853722a4d63c6d9d4227c5889c0f9080d6b759c19100/sqlalchemy_jsonfield-1.0.3.tar.gz", hash = "sha256:162099ff6b6f475105afff90b15da114cbd87fd18df94e7d091df9f9d0e4f69f", size = 15495, upload-time = "2026-05-11T19:54:11.833Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/3a/5c75c8032d8b066cb6e757cd58a741084fbb3764a5cbb76c34ed9190a1e7/sqlalchemy_jsonfield-1.0.3-py3-none-any.whl", hash = "sha256:a73ec0685a9c4330ca1374d4ff9c169748f9d29aacb5b3380f617fb9e597edfa", size = 10170, upload-time = "2026-05-11T19:54:10.738Z" }, -] - -[[package]] -name = "sqlalchemy-utils" -version = "0.42.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/7d/eb9565b6a49426552a5bf5c57e7c239c506dc0e4e5315aec6d1e8241dc7c/sqlalchemy_utils-0.42.1.tar.gz", hash = "sha256:881f9cd9e5044dc8f827bccb0425ce2e55490ce44fc0bb848c55cc8ee44cc02e", size = 130789, upload-time = "2025-12-13T03:14:13.591Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/25/7400c18c3ee97914cc99c90007795c00a4ec5b60c853b49db7ba24d11179/sqlalchemy_utils-0.42.1-py3-none-any.whl", hash = "sha256:243cfe1b3a1dae3c74118ae633f1d1e0ed8c787387bc33e556e37c990594ac80", size = 91761, upload-time = "2025-12-13T03:14:15.014Z" }, +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, ] [[package]] @@ -1806,6 +1591,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, ] +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[package]] +name = "svcs" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/5e/89463bf7613fe723801b8f7000f37c4c36486adcda0d115ca417e513c7cc/svcs-26.2.0.tar.gz", hash = "sha256:129efb89586c2c52295ee00b00562af7d85d5535dad149534993e25fccbd76a6", size = 915193, upload-time = "2026-08-24T12:54:16.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/75/57ff9e594ee14d0948d3a77546894c5eb89616eeb7d8989962d6bcd1557b/svcs-26.2.0-py3-none-any.whl", hash = "sha256:a1f074f345195b961fbf29af07f6ce181f7598813793773db20cf43870949c81", size = 22319, upload-time = "2026-08-24T12:54:14.953Z" }, +] + [[package]] name = "tabulate" version = "0.10.0" @@ -1842,6 +1662,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, ] +[[package]] +name = "typer" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -1851,6 +1686,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "tzdata" version = "2026.4" @@ -1883,15 +1730,103 @@ wheels = [ ] [[package]] -name = "werkzeug" -version = "2.2.3" +name = "uuid6" +version = "2025.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/b7/4c0f736ca824b3a25b15e8213d1bcfc15f8ac2ae48d1b445b310892dc4da/uuid6-2025.0.1.tar.gz", hash = "sha256:cd0af94fa428675a44e32c5319ec5a3485225ba2179eefcf4c3f205ae30a81bd", size = 13932, upload-time = "2025-07-04T18:30:35.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b2/93faaab7962e2aa8d6e174afb6f76be2ca0ce89fde14d3af835acebcaa59/uuid6-2025.0.1-py3-none-any.whl", hash = "sha256:80530ce4d02a93cdf82e7122ca0da3ebbbc269790ec1cb902481fa3e9cc9ff99", size = 6979, upload-time = "2025-07-04T18:30:34.001Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "click" }, + { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/3c/baaebf3235c87d61d6593467056d5a8fba7c75ac838b8d100a5e64eba7a0/Werkzeug-2.2.3.tar.gz", hash = "sha256:2e1ccc9417d4da358b9de6f174e3ac094391ea1d4fbef2d667865d819dfd0afe", size = 845884, upload-time = "2023-02-14T17:18:44.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/f8/9da63c1617ae2a1dec2fbf6412f3a0cfe9d4ce029eccbda6e1e4258ca45f/Werkzeug-2.2.3-py3-none-any.whl", hash = "sha256:56433961bc1f12533306c624f3be5e744389ac61d722175d543e1751285da612", size = 233551, upload-time = "2023-02-14T17:18:42.614Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" }, + { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" }, + { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" }, + { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, ] [[package]] @@ -1927,44 +1862,10 @@ wheels = [ ] [[package]] -name = "wtforms" -version = "3.2.2" +name = "zipp" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/91/ed9b517da898e3fb747566aa3c12a734bd64ea7449a0d25ec74ce8f8b8eb/wtforms-3.2.2.tar.gz", hash = "sha256:7b00c73f8670f35d4edb0293dcd81b980528bee72fd662b182aaba27ae570b93", size = 139583, upload-time = "2026-05-03T05:53:44.147Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/76/bb225c8300f3a0ba28e01df51419c6c9574a297c43d71b29048e03b65deb/wtforms-3.2.2-py3-none-any.whl", hash = "sha256:72b90d5d921bd3119252069cf0301e9c13915f9e52792652bc91c5dda4b79e56", size = 158656, upload-time = "2026-05-03T05:53:46.072Z" }, -] - -[[package]] -name = "yarl" -version = "1.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, - { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, - { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, - { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, - { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, - { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, - { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, - { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, - { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, - { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, - { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, - { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, - { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] diff --git a/infra/README.md b/infra/README.md index 4866222..5a4776e 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,18 +1,48 @@ # Infrastructure -Provisionnement Terraform de la machine on-premise (serveur physique, accessible en SSH). +Provisionnement Terraform des machines on-premise. Terraform prepare la machine, GitHub Actions +deploie l'application : voir l'[ADR 0010](../docs/adr/0010-terraform-provisionne-github-actions-deploie.md). +Rien ici ne construit d'image ni ne lance de conteneur. - `terraform/modules` : modules reutilisables. - `k3s` : installe un cluster k3s single-node sur une machine distante via SSH (script officiel `get.k3s.io`) et rapatrie le kubeconfig en local. -- `terraform/environments/` : racines Terraform, une par environnement. - - `dev` : instancie le module `k3s` sur le serveur de l'ecole. - - `prod` : non initialise, voir le ticket dedie. +- `terraform/environments/` : une racine par machine provisionnee. + - `vm-eni` : la VM `eadl-2025-nantes-g3`, qui porte les environnements `rec` et `prod` + ([ADR 0009](../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Installe Docker, + execute `scripts/provision-host.sh`, enregistre le runner GitHub Actions. + - `k3s-cible` : le cluster k3s, cible a terme de `docs/architecture/10-infra.md`. Jamais + applique. -## Usage (environments/dev) +## Usage (environments/vm-eni) ```bash -cd infra/terraform/environments/dev +cd infra/terraform/environments/vm-eni +cp terraform.tfvars.example terraform.tfvars +terraform init +terraform apply +``` + +`terraform.tfvars` est ignore par git. Trois valeurs sont a renseigner avant l'apply : + +- `proprietaire` : l'utilisateur qui possede `/srv/enervision` et fait tourner le runner. Il doit + deja exister sur la machine. +- `runner_version` : a epingler depuis . +- `runner_token` : jeton d'enregistrement, valable une heure et pour une seule inscription. + Parametres du depot, Actions, Runners, New self-hosted runner. Seul un administrateur du depot + peut le creer. + +Apres l'apply, la machine porte `/srv/enervision/rec` et `/srv/enervision/prod`, chacun avec son +`.env` et son certificat. Le premier demarrage reste manuel, `make stack-up` dans chaque dossier ; +les suivants sont joues par le runner a chaque push sur `dev` et sur `main`. + +Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le +desinscrit pas. + +## Usage (environments/k3s-cible) + +```bash +cd infra/terraform/environments/k3s-cible cp terraform.tfvars.example terraform.tfvars # renseigner ssh_host / ssh_private_key_path terraform init terraform apply diff --git a/infra/proxy/README.md b/infra/proxy/README.md index 558e8b9..031664a 100644 --- a/infra/proxy/README.md +++ b/infra/proxy/README.md @@ -9,12 +9,18 @@ Terminaison TLS et routage de la stack déployée. Seul composant publié sur le - `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git. - `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`. -Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la +Pas de `Dockerfile` : l'image officielle `nginx:1.31-alpine` est utilisée telle quelle et la configuration est montée en volume par `docker-compose.prod.yml`. L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4 ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports. +Les ports publiés sont `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`, 80 et 443 par défaut. Quand deux +environnements partagent la machine ([ADR 0009](../../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), +la recette publie `8443` et ramène son port 80 sur `127.0.0.1:8081` : la redirection ci-dessous +renvoie vers `https://$host` sans port, donc vers la production. `PUBLIC_ORIGIN` porte alors +l'origine avec son port pour le CORS et le lien de réinitialisation. + ## Routage | Chemin | Destination | Remarque | @@ -82,7 +88,7 @@ docker run --rm \ -v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \ -v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \ -v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \ - nginx:1.28-alpine nginx -t + nginx:1.31-alpine nginx -t ``` Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image. diff --git a/infra/terraform/environments/dev/.terraform.lock.hcl b/infra/terraform/environments/k3s-cible/.terraform.lock.hcl similarity index 100% rename from infra/terraform/environments/dev/.terraform.lock.hcl rename to infra/terraform/environments/k3s-cible/.terraform.lock.hcl diff --git a/infra/terraform/environments/dev/backend.tf b/infra/terraform/environments/k3s-cible/backend.tf similarity index 100% rename from infra/terraform/environments/dev/backend.tf rename to infra/terraform/environments/k3s-cible/backend.tf diff --git a/infra/terraform/environments/dev/frontend.tf b/infra/terraform/environments/k3s-cible/frontend.tf similarity index 100% rename from infra/terraform/environments/dev/frontend.tf rename to infra/terraform/environments/k3s-cible/frontend.tf diff --git a/infra/terraform/environments/dev/main.tf b/infra/terraform/environments/k3s-cible/main.tf similarity index 100% rename from infra/terraform/environments/dev/main.tf rename to infra/terraform/environments/k3s-cible/main.tf diff --git a/infra/terraform/environments/dev/outputs.tf b/infra/terraform/environments/k3s-cible/outputs.tf similarity index 100% rename from infra/terraform/environments/dev/outputs.tf rename to infra/terraform/environments/k3s-cible/outputs.tf diff --git a/infra/terraform/environments/dev/terraform.tfvars.example b/infra/terraform/environments/k3s-cible/terraform.tfvars.example similarity index 100% rename from infra/terraform/environments/dev/terraform.tfvars.example rename to infra/terraform/environments/k3s-cible/terraform.tfvars.example diff --git a/infra/terraform/environments/dev/variables.tf b/infra/terraform/environments/k3s-cible/variables.tf similarity index 100% rename from infra/terraform/environments/dev/variables.tf rename to infra/terraform/environments/k3s-cible/variables.tf diff --git a/infra/terraform/environments/k3s-cible/versions.tf b/infra/terraform/environments/k3s-cible/versions.tf new file mode 100644 index 0000000..d793df3 --- /dev/null +++ b/infra/terraform/environments/k3s-cible/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.7" + + required_providers { + null = { + source = "hashicorp/null" + version = "~> 3.2" + } + } + + backend "local" { + path = "terraform.tfstate" + } +} diff --git a/infra/terraform/environments/vm-eni/.terraform.lock.hcl b/infra/terraform/environments/vm-eni/.terraform.lock.hcl new file mode 100644 index 0000000..866d1af --- /dev/null +++ b/infra/terraform/environments/vm-eni/.terraform.lock.hcl @@ -0,0 +1,23 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/null" { + version = "3.3.2" + constraints = "~> 3.2" + hashes = [ + "h1:IQ1qrkht1sC1nibUR+AJ3ulryyhVDHfCHZhoJi0sg2Y=", + "zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08", + "zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55", + "zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27", + "zh:64a8f9ce7852d9efc5b464c12306c946366d59f5e2757def97969c9fd64bd1d6", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:92a374fb736a52f465283326d0a5bf4f495132eb99be209dfb4c75ec803fe8db", + "zh:98da9c42785d27a50f0604758bcb61a30f6278b9f2acd92bb3b2046e0e71916c", + "zh:b0f7896fae554729cdf4a24ac06359a050cff5817e6cd8597cba8a4ae01a7409", + "zh:bc8179ee35d67c72fb03012e7023b9f9816f033a7ec4109c001dd6d29752e812", + "zh:d23a598f713bfb6098bc003571d7de90b5a33b78f9be240488252fe5f3c2a60d", + "zh:d2855b922ea345dbd89ea287e4c6c4757e38bc0aaffeb2b79aa0b8004f9c53ff", + "zh:d3a60422bc6a2f9244d076c5222c07060c826ef91bdbaf4634cb752b86057473", + "zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba", + ] +} diff --git a/infra/terraform/environments/vm-eni/main.tf b/infra/terraform/environments/vm-eni/main.tf new file mode 100644 index 0000000..44a9241 --- /dev/null +++ b/infra/terraform/environments/vm-eni/main.tf @@ -0,0 +1,137 @@ +# Pourquoi : Terraform provisionne la machine, GitHub Actions la deploie (ADR 0010). Rien ici ne +# construit d'image ni ne lance de conteneur : la livraison reste portee par `deploy.yml` et +# `make stack-up`, et un `apply` n'interrompt pas la stack qui tourne. +# Piege : seul le bloc `triggers` d'un `null_resource` atterrit dans le state. Ni le jeton du +# runner ni la cle SSH n'y figurent, et ne doivent jamais y etre ajoutes pour forcer un rejeu. +# Contrainte : pas de provisioner `destroy` sur le runner. Il imposerait une connexion ne lisant +# que `self`, donc le chemin de la cle SSH dans le state, et `svc.sh uninstall` ne desinscrit pas +# le runner cote GitHub : le retrait reste manuel, depuis les parametres du depot. +# Ref : ADR 0009 pour les deux environnements, `scripts/provision-host.sh` pour leur contenu. + +locals { + sudo = var.ssh_user == "root" ? "" : "sudo " + en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --" + provisionneur = "${path.root}/../../../../scripts/provision-host.sh" + runner_archive = "actions-runner-linux-x64-${var.runner_version}.tar.gz" + # Substitution shell, evaluee par le sh -c distant : un nom de runner doit etre unique dans + # le depot, le nom d'hote l'est deja et le reste si cette racine sert a une autre machine. + runner_nom = var.runner_nom != "" ? var.runner_nom : "$(hostname -s)" +} + +resource "null_resource" "docker_engine" { + triggers = { + hote = var.ssh_host + user = var.proprietaire + } + + connection { + type = "ssh" + host = var.ssh_host + port = var.ssh_port + user = var.ssh_user + private_key = file(pathexpand(var.ssh_private_key_path)) + timeout = "5m" + } + + provisioner "remote-exec" { + inline = [ + <<-EOT + set -eu + id ${var.proprietaire} >/dev/null 2>&1 || { + echo "l'utilisateur ${var.proprietaire} n'existe pas sur la machine" >&2 + exit 1 + } + command -v docker >/dev/null || ${local.sudo}sh -c 'curl -fsSL https://get.docker.com | sh' + ${local.sudo}systemctl enable --now docker + ${local.sudo}usermod -aG docker ${var.proprietaire} + ${local.sudo}docker compose version + EOT + ] + } +} + +# `provision-host.sh` verifie lui-meme docker, compose et la sortie HTTPS, puis prepare un clone +# par environnement, son `.env` et son certificat. Il est rejouable : un `.env` existant n'est +# jamais reecrit, un certificat present jamais regenere. +resource "null_resource" "environnements" { + depends_on = [null_resource.docker_engine] + + triggers = { + script = filesha256(local.provisionneur) + racine = var.racine + depot = var.depot_url + } + + connection { + type = "ssh" + host = var.ssh_host + port = var.ssh_port + user = var.ssh_user + private_key = file(pathexpand(var.ssh_private_key_path)) + timeout = "5m" + } + + provisioner "file" { + source = local.provisionneur + destination = "/tmp/provision-host.sh" + } + + provisioner "remote-exec" { + inline = [ + <<-EOT + set -eu + ${local.sudo}env RACINE='${var.racine}' \ + REPO_URL='${var.depot_url}' \ + PROPRIETAIRE='${var.proprietaire}' \ + PUBLIC_IP='${var.adresse_publique}' \ + bash /tmp/provision-host.sh + rm -f /tmp/provision-host.sh + EOT + ] + } +} + +# Piege : le jeton d'enregistrement expire en une heure. Un `apply` rejoue cette ressource des +# que `runner_version`, `runner_labels` ou `runner_nom` change, et redemande donc un jeton frais. +resource "null_resource" "runner_github" { + depends_on = [null_resource.environnements] + + triggers = { + version = var.runner_version + labels = var.runner_labels + nom = local.runner_nom + dossier = var.runner_dossier + } + + connection { + type = "ssh" + host = var.ssh_host + port = var.ssh_port + user = var.ssh_user + private_key = file(pathexpand(var.ssh_private_key_path)) + timeout = "5m" + } + + provisioner "remote-exec" { + inline = [ + <<-EOT + set -eu + ${local.sudo}install -d -o ${var.proprietaire} -g ${var.proprietaire} ${var.runner_dossier} + if [ ! -x ${var.runner_dossier}/config.sh ]; then + curl -fsSL -o /tmp/${local.runner_archive} \ + https://github.com/actions/runner/releases/download/v${var.runner_version}/${local.runner_archive} + ${local.sudo}tar -xzf /tmp/${local.runner_archive} -C ${var.runner_dossier} + ${local.sudo}chown -R ${var.proprietaire}:${var.proprietaire} ${var.runner_dossier} + rm -f /tmp/${local.runner_archive} + fi + if [ ! -f ${var.runner_dossier}/.runner ]; then + ${local.en_tant_que} sh -c 'cd ${var.runner_dossier} && ./config.sh --unattended --replace \ + --url ${var.runner_url} --token ${var.runner_token} \ + --labels ${var.runner_labels} --name ${local.runner_nom} --work _work' + ${local.sudo}${var.runner_dossier}/svc.sh install ${var.proprietaire} + fi + ${local.sudo}${var.runner_dossier}/svc.sh start + EOT + ] + } +} diff --git a/infra/terraform/environments/vm-eni/outputs.tf b/infra/terraform/environments/vm-eni/outputs.tf new file mode 100644 index 0000000..48ee369 --- /dev/null +++ b/infra/terraform/environments/vm-eni/outputs.tf @@ -0,0 +1,9 @@ +output "machine" { + description = "Machine provisionnee et racine qui porte un clone par environnement." + value = "${var.ssh_user}@${var.ssh_host}:${var.racine}" +} + +output "runner" { + description = "Dossier d'installation du runner et libelles supplementaires annonces a GitHub." + value = "${var.runner_dossier} (${var.runner_labels})" +} diff --git a/infra/terraform/environments/vm-eni/terraform.tfvars.example b/infra/terraform/environments/vm-eni/terraform.tfvars.example new file mode 100644 index 0000000..9f5df93 --- /dev/null +++ b/infra/terraform/environments/vm-eni/terraform.tfvars.example @@ -0,0 +1,22 @@ +ssh_host = "10.101.200.37" +ssh_port = 22 +ssh_user = "root" +ssh_private_key_path = "~/.ssh/id_ed25519" + +# Utilisateur qui possede la racine et fait tourner le runner. Il doit deja exister sur la +# machine : git refuse les depots appartenant a un autre utilisateur, et un .env en 600 lui +# echapperait. +proprietaire = "enervision" + +# Epingler une version reelle : https://github.com/actions/runner/releases +runner_version = "2.330.0" + +# Jeton d'enregistrement du runner, valable une heure et pour une seule inscription : +# Parametres du depot > Actions > Runners > New self-hosted runner. Seul un administrateur du +# depot peut le creer. terraform.tfvars est ignore par git, mais le jeton ne doit pas y rester +# apres l'apply. +runner_token = "A_RENSEIGNER" + +# Nom du runner cote GitHub. Vide par defaut : le nom d'hote de la machine. A renseigner +# seulement si deux runners doivent tourner sur la meme machine, leurs noms devant differer. +# runner_nom = "eni-g3-bis" diff --git a/infra/terraform/environments/vm-eni/variables.tf b/infra/terraform/environments/vm-eni/variables.tf new file mode 100644 index 0000000..29be228 --- /dev/null +++ b/infra/terraform/environments/vm-eni/variables.tf @@ -0,0 +1,90 @@ +variable "ssh_host" { + type = string + description = "Adresse de la VM ENI qui porte les deux environnements (ADR 0009)." +} + +variable "ssh_port" { + type = number + description = "Port SSH de la VM." + default = 22 +} + +variable "ssh_user" { + type = string + description = "Utilisateur SSH du provisionnement. Different de root, les commandes privilegiees sont prefixees par sudo." + default = "root" +} + +variable "ssh_private_key_path" { + type = string + description = "Chemin local vers la cle privee SSH. L'authentification par mot de passe n'est volontairement pas prise en charge : une variable de mot de passe finit en clair dans le state ou dans les triggers." + sensitive = true +} + +variable "proprietaire" { + type = string + description = "Utilisateur qui possede la racine et fait tourner le runner. Il doit exister sur la machine : git refuse les depots appartenant a un autre utilisateur, et un .env en 600 lui echapperait." + + validation { + condition = can(regex("^[a-z_][a-z0-9_-]*$", var.proprietaire)) + error_message = "proprietaire doit etre un nom d'utilisateur Unix valide." + } +} + +variable "racine" { + type = string + description = "Dossier qui porte un clone du depot par environnement." + default = "/srv/enervision" +} + +variable "depot_url" { + type = string + description = "URL de clonage du depot, passee a provision-host.sh." + default = "https://github.com/ineszang/ProjetPiscine_EnerVision.git" +} + +variable "adresse_publique" { + type = string + description = "Adresse annoncee dans les certificats auto-signes. Vide : la premiere adresse de la VM." + default = "" +} + +variable "runner_url" { + type = string + description = "Depot GitHub auquel le runner s'enregistre." + default = "https://github.com/ineszang/ProjetPiscine_EnerVision" +} + +variable "runner_version" { + type = string + description = "Version d'actions-runner a installer, sans le v initial (ex: 2.330.0). Voir https://github.com/actions/runner/releases." + + validation { + condition = can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.runner_version)) + error_message = "runner_version doit etre epinglee explicitement (ex: 2.330.0), sinon l'installation cesse d'etre reproductible." + } +} + +variable "runner_token" { + type = string + description = "Jeton d'enregistrement du runner. Expire au bout d'une heure et ne vaut que pour une inscription : Parametres du depot > Actions > Runners > New self-hosted runner. Seul un administrateur du depot peut le creer." + sensitive = true +} + +variable "runner_labels" { + type = string + description = "Libelles supplementaires du runner. deploy.yml cible [self-hosted, linux, eni-g3], les deux premiers etant poses par GitHub." + default = "eni-g3" +} + +variable "runner_nom" { + type = string + description = "Nom du runner cote GitHub, unique dans le depot. Vide : le nom d'hote de la machine, qui reste unique si cette racine est reprise pour une seconde VM. A renseigner pour faire tourner deux runners sur la meme machine." + default = "" +} + +variable "runner_dossier" { + type = string + description = "Dossier d'installation du runner sur la machine." + default = "/opt/actions-runner" +} diff --git a/infra/terraform/environments/dev/versions.tf b/infra/terraform/environments/vm-eni/versions.tf similarity index 100% rename from infra/terraform/environments/dev/versions.tf rename to infra/terraform/environments/vm-eni/versions.tf diff --git a/infra/terraform/modules/k3s/main.tf b/infra/terraform/modules/k3s/main.tf index 533191b..ee2b995 100644 --- a/infra/terraform/modules/k3s/main.tf +++ b/infra/terraform/modules/k3s/main.tf @@ -5,6 +5,9 @@ locals { kubeconfig_cmd = "${local.sudo_prefix}cat /etc/rancher/k3s/k3s.yaml" } +# Piege : un provisioner `destroy` impose que tout le bloc `connection` ne lise que `self`, sinon +# `terraform init` refuse le module. D'ou la connexion batie sur `triggers`, ou ne figurent que +# l'adresse, le port, l'utilisateur et le chemin de la cle : jamais la cle ni un mot de passe. resource "null_resource" "k3s_install" { triggers = { ssh_host = var.ssh_host @@ -14,6 +17,13 @@ resource "null_resource" "k3s_install" { k3s_version = var.k3s_version disable_components = join(",", var.k3s_disable_components) sudo_prefix = local.sudo_prefix + ssh_host = var.ssh_host + ssh_port = tostring(var.ssh_port) + ssh_user = var.ssh_user + ssh_key_path = var.ssh_private_key_path + sudo_prefix = local.sudo_prefix + k3s_version = var.k3s_version + disable_components = join(",", var.k3s_disable_components) } connection { diff --git a/ml/.dockerignore b/ml/.dockerignore new file mode 100644 index 0000000..5784a2c --- /dev/null +++ b/ml/.dockerignore @@ -0,0 +1,6 @@ +.venv +data +mlruns +mlflow.db* +models +.env diff --git a/ml/.env.example b/ml/.env.example new file mode 100644 index 0000000..65e2ef5 --- /dev/null +++ b/ml/.env.example @@ -0,0 +1 @@ +MLFLOW_DB_PASSWORD=change-me diff --git a/ml/Dockerfile b/ml/Dockerfile new file mode 100644 index 0000000..070b752 --- /dev/null +++ b/ml/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.14-slim +RUN pip install --no-cache-dir --only-binary :all: mlflow==3.16.1 psycopg2-binary==2.9.13 +RUN useradd --create-home --uid 1000 mlflow \ + && mkdir /mlartifacts \ + && chown mlflow /mlartifacts +USER mlflow +EXPOSE 5000 diff --git a/ml/README.md b/ml/README.md index 21ddd85..0dd5d7b 100644 --- a/ml/README.md +++ b/ml/README.md @@ -57,6 +57,50 @@ validation. La coupure est **chronologique**, jamais un tirage aleatoire de lign aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs lags/moyennes glissantes, une fuite qui masquerait un surapprentissage. +## Serveur MLflow (conteneur) + +Premiere utilisation : copier `.env.example` en `.env` et y choisir un mot de passe PostgreSQL +(lettres et chiffres uniquement). Le fichier `.env` est ignore par git. + +```bash +cp .env.example .env +``` + +Un serveur MLflow (PostgreSQL pour les metadonnees, volume pour les artefacts) se lance avec +Docker. Prerequis : Docker Desktop demarre. + +```bash +make mlflow-up +``` + +La cible vérifie que `MLFLOW_DB_PASSWORD` (définie dans `ml/.env`) ne contient que des lettres et +des chiffres avant de démarrer le serveur : ce mot de passe est interpolé directement dans l'URI +PostgreSQL (`postgresql://mlflow:${MLFLOW_DB_PASSWORD}@...`), un caractère spécial la rendrait +invalide sans message d'erreur clair. + +Interface : http://localhost:5000. Entrainer vers ce serveur : + +``` +uv run python -m enervision_ml.train --csv data/all_sites_combined.csv --mlflow-tracking-uri http://localhost:5000 +``` + +Arreter : `docker compose -f docker-compose.mlflow.yml down` (ajouter `-v` pour effacer aussi les +runs et les modeles). + +Pour voir les runs dans l'interface (MLflow 3.x) : + +- Passer le selecteur en haut a gauche sur **Model training**. Le mode **GenAI** affiche des + traces LLM et reste vide pour un entrainement LightGBM. +- **Runs** liste les entrainements, **Models** les artefacts de modele de chaque run (tous nommes + `model`), et **Model registry** les versions numerotees de `consumption-forecast-lightgbm`. + +Limites : l'identifiant PostgreSQL du compose est fixe a `mlflow`, le mot de passe vient de la +variable obligatoire `MLFLOW_DB_PASSWORD` (aucune valeur par defaut, le compose refuse de +demarrer sans elle) -- ce mot de passe est choisi lors de la copie de `.env.example`, il ne +convient donc qu'au developpement local tel quel. Un deploiement partage demandera des secrets, +de l'authentification et un stockage d'artefacts dedie (S3/MinIO). Le port 5000 doit etre libre : arreter `mlflow ui` avant, +ou changer le mapping (`"5001:5000"`) dans le compose. + ## Scoring ```bash @@ -83,6 +127,13 @@ section 2 : fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne distinguerait pas deux versions du modele. +**Le scoring ne lit pas le Model Registry.** Le fichier charge par `--model` est local +(`models/lightgbm-consumption.txt`), independant des versions enregistrees dans le +**Model registry** MLflow (`consumption-forecast-lightgbm`). `train.py` enregistre bien une +version a chaque entrainement (tracabilite), mais aucun alias (`champion` par exemple) n'est +pose, et `enervision_ml.score` ne les lit pas. Le registre sert aujourd'hui a la tracabilite des +entrainements, pas au deploiement du modele utilise en scoring. + En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future" calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le pipeline sans base joignable. @@ -103,7 +154,7 @@ prevision (utile plus tard pour comparer prevision et realise, surveillance de d uv run ruff check . # lint uv run ruff format . # format uv run mypy enervision_ml tests # typage strict -uv run pytest # tests +uv run pytest # tests + couverture (ml/coverage.xml avec --cov-report=xml, lu par Sonar) ``` Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`, @@ -111,12 +162,23 @@ Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-li ## Ou ecrire les tests -Aucun test ne touche PostgreSQL ni un serveur MLflow distant : `enervision_ml.data.load_from_csv` -et le chargement CSV de test suffisent a exercer `build_features` sur des donnees reelles ou -synthetiques, et `enervision_ml.train.train()` accepte un `tracking_uri` SQLite isole (`tmp_path` -pytest) pour un test de bout en bout sans effet de bord. `enervision_ml.data.load_from_database` -n'est pas encore couvert : il n'existe aucune base PostgreSQL a interroger en CI ni dans cet -environnement de developpement pour le moment. +Deux regimes, separes par le marqueur `integration` que `pytest` ecarte par defaut. + +**Sans base** : `enervision_ml.data.load_from_csv` et le chargement CSV de test suffisent a +exercer `build_features` sur des donnees reelles ou synthetiques, et `enervision_ml.train.train()` +accepte un `tracking_uri` SQLite isole (`tmp_path` pytest) pour un test de bout en bout sans effet +de bord. + +**Avec base**, sous `integration` : `test_data_integration.py` confronte les neuf colonnes du +contrat au schema Alembic reel, et `test_score_integration.py` verifie les contraintes de +`prediction` depuis le code qui ecrit. Les fixtures sont dans `tests/conftest.py`, qui refuse de +demarrer si `ML_DATABASE_URL` ne vise pas `enervision_test`. + + make db-up migrate-test ml-test-integration + +Regle a tenir : **toute requete SQL nouvelle porte un test `integration`**. Le schema vit dans +`apps/backend/alembic`, pas ici : sans ce garde-fou, une migration qui renomme une colonne casse +le pipeline en production sans qu'aucun test ne rougisse. ## Piege a connaitre diff --git a/ml/docker-compose.mlflow.yml b/ml/docker-compose.mlflow.yml new file mode 100644 index 0000000..92fafbf --- /dev/null +++ b/ml/docker-compose.mlflow.yml @@ -0,0 +1,32 @@ +services: + mlflow-db: + image: postgres:17 + environment: + POSTGRES_USER: mlflow + POSTGRES_PASSWORD: ${MLFLOW_DB_PASSWORD:?definir MLFLOW_DB_PASSWORD dans ml/.env} + POSTGRES_DB: mlflow + volumes: + - mlflow-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mlflow"] + interval: 5s + retries: 10 + + mlflow: + build: . + depends_on: + mlflow-db: + condition: service_healthy + ports: + - "127.0.0.1:5000:5000" + volumes: + - mlflow-artifacts:/mlartifacts + environment: + MLFLOW_DB_PASSWORD: ${MLFLOW_DB_PASSWORD} + entrypoint: [ "/bin/sh", "-c" ] + command: + - exec mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri "postgresql://mlflow:$$MLFLOW_DB_PASSWORD@mlflow-db:5432/mlflow" --artifacts-destination /mlartifacts --serve-artifacts + +volumes: + mlflow-db-data: + mlflow-artifacts: diff --git a/ml/enervision_ml/data.py b/ml/enervision_ml/data.py index f8e7bf9..e74da91 100644 --- a/ml/enervision_ml/data.py +++ b/ml/enervision_ml/data.py @@ -43,6 +43,10 @@ NUMERIC_COLUMNS = [ "capacity_kw", ] +# Piege : `is_working_hours` est nullable et entre dans les features. Toujours `float64`, jamais +# `bool` : `astype(bool)` ferait un `True` d'une absence, et les deux chargeurs divergeraient. +FLAG_COLUMNS = ["is_working_hours"] + _READING_QUERY = text( """ SELECT @@ -76,7 +80,7 @@ _RECENT_READING_QUERY = text( s.capacity_kw FROM reading r JOIN site s ON s.site_id = r.site_id - WHERE r.timestamp >= :since + WHERE r.timestamp >= :since AND r.timestamp <= :until ORDER BY r.site_id, r.timestamp """ ) @@ -90,22 +94,34 @@ def load_from_database(connection: Connectable) -> pd.DataFrame: return _typer(frame[OUTPUT_COLUMNS]) -def load_recent_from_database(connection: Connectable, *, since: datetime) -> pd.DataFrame: - """Lit `reading` + `site` depuis `since` seulement, pour le scoring. +def load_recent_from_database( + connection: Connectable, *, since: datetime, until: datetime +) -> pd.DataFrame: + """Lit `reading` + `site` sur la fenetre `[since, until]`, pour le scoring. - Piege evite : un `SELECT` sans borne sur l'hypertable complete juste pour scorer le prochain - pas horaire serait la meme erreur que celle corrigee sur `GET /readings` (fenetre non + Piege evite cote bas : un `SELECT` sans borne sur l'hypertable complete juste pour scorer le + prochain pas horaire serait la meme erreur que celle corrigee sur `GET /readings` (fenetre non plafonnee sur une table pouvant porter des annees d'historique). + + Piege evite cote haut : `until` est obligatoire, et c'est ce qui donne son sens a `--now`. + Sans lui, `build_scoring_frame` repartait de la derniere lecture de toute la table quel que + soit l'instant demande, donc `target_at` valait toujours "fin du jeu + 1h" et l'age de la + derniere lecture devenait negatif sans que rien ne le signale. """ - frame = pd.read_sql(_RECENT_READING_QUERY, connection, params={"since": since}) + frame = pd.read_sql(_RECENT_READING_QUERY, connection, params={"since": since, "until": until}) return _typer(frame[OUTPUT_COLUMNS]) def load_from_csv(csv_path: Path) -> pd.DataFrame: - """Lit le jeu de donnees CSV historique (chemin de demarrage, hors base).""" + """Lit le jeu de donnees CSV historique (chemin de demarrage, hors base). + + `is_working_hours` passe par `_typer` comme le chemin base, et non par un `astype(bool)` : le + fichier livre porte cette colonne en `0`/`1`, donc une case vide arrive en `NaN` et `astype` + la rendrait `True` sans rien signaler. Les deux chargeurs rendent ainsi le meme schema, ce que + `docs/ML-START.md` promet. + """ frame = pd.read_csv(csv_path, parse_dates=["timestamp"]) frame["capacity_kw"] = float("nan") - frame["is_working_hours"] = frame["is_working_hours"].astype(bool) return _typer(frame[OUTPUT_COLUMNS]) @@ -120,6 +136,10 @@ def _typer(frame: pd.DataFrame) -> pd.DataFrame: n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas seulement `capacity_kw`. + Les colonnes de `FLAG_COLUMNS` sont en outre ramenees a `float64` : ce sont des drapeaux + nullables, et c'est le seul dtype qui survive a l'absence sans inventer de valeur. Sans cela, + le meme chargeur rendrait `bool`, `int64` ou `float64` selon le contenu de la fenetre lue. + Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement `NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au @@ -128,4 +148,6 @@ def _typer(frame: pd.DataFrame) -> pd.DataFrame: typee = frame.copy() for colonne in NUMERIC_COLUMNS: typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce") + for colonne in FLAG_COLUMNS: + typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce").astype("float64") return typee diff --git a/ml/enervision_ml/score.py b/ml/enervision_ml/score.py index c0b7e3f..3bc0a67 100644 --- a/ml/enervision_ml/score.py +++ b/ml/enervision_ml/score.py @@ -204,7 +204,8 @@ def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.D instant = now or ( brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) ) - return brute[brute["timestamp"] >= instant - LOOKBACK], instant + fenetre = (brute["timestamp"] >= instant - LOOKBACK) & (brute["timestamp"] <= instant) + return brute[fenetre], instant def _score_frame( @@ -240,7 +241,7 @@ def run_scoring( engine = create_engine(config.database_url()) try: instant = now or datetime.now(UTC) - recent = load_recent_from_database(engine, since=instant - LOOKBACK) + recent = load_recent_from_database(engine, since=instant - LOOKBACK, until=instant) resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) reference = model_reference(model_path) diff --git a/ml/enervision_ml/train.py b/ml/enervision_ml/train.py index fad5b89..2d4e25c 100644 --- a/ml/enervision_ml/train.py +++ b/ml/enervision_ml/train.py @@ -181,7 +181,11 @@ def _log_to_mlflow( ) mlflow.log_metrics({f"model_{cle}": valeur for cle, valeur in model_metrics.items()}) mlflow.log_metrics({f"baseline_{cle}": valeur for cle, valeur in baseline_metrics.items()}) - mlflow.lightgbm.log_model(booster, name="model") + mlflow.lightgbm.log_model( + booster, + name="model", + registered_model_name="consumption-forecast-lightgbm", + ) mlflow.log_artifact(str(model_output)) diff --git a/ml/pyproject.toml b/ml/pyproject.toml index 1589c92..9a614a4 100644 --- a/ml/pyproject.toml +++ b/ml/pyproject.toml @@ -17,6 +17,7 @@ dev = [ "ruff>=0.16.7", "mypy>=2.3.1", "pytest>=9.1.1", + "pytest-cov>=7.1.0", "pandas-stubs>=3.0.5.260914", ] @@ -75,5 +76,14 @@ ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "-q --strict-markers -m 'not integration'" +addopts = "-q --strict-markers -m 'not integration' --cov=enervision_ml --cov-report=term-missing" markers = ["integration: requiert une base PostgreSQL joignable"] + +# Rapport lu par SonarCloud (`ml/coverage.xml`, cf. sonar-project.properties), meme mecanisme que +# apps/backend. Pas de seuil ici : celui de la quality gate porte sur le code nouveau. +[tool.coverage.run] +source = ["enervision_ml"] +branch = true + +[tool.coverage.report] +show_missing = true diff --git a/ml/tests/__init__.py b/ml/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ml/tests/conftest.py b/ml/tests/conftest.py new file mode 100644 index 0000000..33f65b9 --- /dev/null +++ b/ml/tests/conftest.py @@ -0,0 +1,332 @@ +"""Piege : deux fixtures d'acces a la base, jamais interchangeables - `connexion_ml` et `parc`. + +`connexion_ml` ouvre une transaction annulee a la fin du test : rien ne subsiste, et rien n'est +visible hors de cette connexion. Elle sert aux fonctions qui recoivent leur connexion en +argument (`load_from_database`, `load_recent_from_database`, `write_predictions`). + +`run_scoring` fabrique en revanche son propre engine depuis `ML_DATABASE_URL` : il ne verrait +pas des lignes semees dans une transaction non validee, et ses propres ecritures survivraient a +l'annulation. Les tests qui l'appellent passent donc par `parc`, qui valide ce qu'il ecrit et +nettoie lui-meme, dans l'ordre impose par les cles etrangeres `RESTRICT`. +""" + +import math +import os +from collections.abc import Iterator +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import lightgbm as lgb +import pandas as pd +import pytest +from sqlalchemy import Connection, Engine, Row, bindparam, create_engine, text +from sqlalchemy.engine import URL, make_url + +from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns + +BASE_ATTENDUE = "enervision_test" + +# Piege : `load_from_database` lit toute la table, et `enervision_test` est partagee entre un run +# local et la CI. Les tests ancrent donc leurs lectures au-dela de tout jeu de donnees reel +# (l'historique s'arrete au 31/12/2024) pour que leur borne `since` ne ramene qu'eux. +ANCRAGE = datetime(2035, 1, 1, tzinfo=UTC) + +SITE_TYPE = "office" +CAPACITY_KW = 100.0 + +_INSERT_SITE = text( + """ + INSERT INTO site (site_id, site_name, site_type, capacity_kw) + VALUES (:site_id, :site_name, :site_type, :capacity_kw) + """ +) + +# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source), ce qui evite +# de creer une ligne `dataset`. `raw_data` est NOT NULL, d'ou le litteral jsonb. +_INSERT_READING = text( + """ + INSERT INTO reading ( + site_id, timestamp, source, consumption_kwh, temperature_celsius, + humidity_percent, solar_irradiance_wm2, is_working_hours, raw_data + ) VALUES ( + :site_id, :timestamp, :source, :consumption_kwh, :temperature_celsius, + :humidity_percent, :solar_irradiance_wm2, :is_working_hours, '{}'::jsonb + ) + """ +) + +_SELECT_PREDICTIONS = text( + """ + SELECT target_at, predicted_value, status, failure_reason, model_reference + FROM prediction + WHERE site_id = :site_id + ORDER BY prediction_id + """ +) + +_INSERT_PREDICTION = text( + """ + INSERT INTO prediction ( + site_id, target_at, target_metric, period_minutes, + predicted_value, model_reference, status, failure_reason + ) VALUES ( + :site_id, :target_at, 'consumption_kwh', 60, + :predicted_value, :model_reference, :status, :failure_reason + ) + """ +) + + +# Ordre impose par les cles etrangeres `RESTRICT` : une lecture avant son site, une prediction +# avant sa lecture. +_SUPPRESSIONS = tuple( + text(requete).bindparams(bindparam("sites", expanding=True)) + for requete in ( + "DELETE FROM prediction WHERE site_id IN :sites", + "DELETE FROM reading WHERE site_id IN :sites", + "DELETE FROM site WHERE site_id IN :sites", + ) +) + + +def insere_site( + connexion: Connection, + *, + site_type: str = SITE_TYPE, + capacity_kw: float | None = CAPACITY_KW, +) -> str: + site_id = f"TEST-{uuid4().hex[:12]}" + connexion.execute( + _INSERT_SITE, + { + "site_id": site_id, + "site_name": "Site de test", + "site_type": site_type, + "capacity_kw": capacity_kw, + }, + ) + return site_id + + +def insere_lectures( + connexion: Connection, + site_id: str, + *, + heures: int, + fin: datetime, + valeur: float = 50.0, + source: str = "api_history", + is_working_hours: bool | None = True, +) -> list[datetime]: + """Grille horaire contigue finissant a `fin`, incluse. + + Contigue parce que les lags de `build_features` sont des `shift()` positionnels : un trou + dans la grille decalerait le lag de 168 h sans qu'aucune erreur ne se declenche. + """ + instants = [fin - timedelta(hours=decalage) for decalage in reversed(range(heures))] + connexion.execute( + _INSERT_READING, + [ + { + "site_id": site_id, + "timestamp": instant, + "source": source, + "consumption_kwh": valeur + math.sin(rang / 12.0) * 10.0, + "temperature_celsius": 15.0, + "humidity_percent": 50.0, + "solar_irradiance_wm2": 0.0, + "is_working_hours": is_working_hours, + } + for rang, instant in enumerate(instants) + ], + ) + return instants + + +def insere_lecture( + connexion: Connection, + site_id: str, + *, + instant: datetime, + consumption_kwh: float | None = 50.0, + source: str = "api_history", + is_working_hours: bool | None = True, +) -> None: + """Une lecture isolee, quand le test pilote sa valeur plutot que sa forme.""" + connexion.execute( + _INSERT_READING, + { + "site_id": site_id, + "timestamp": instant, + "source": source, + "consumption_kwh": consumption_kwh, + "temperature_celsius": 15.0, + "humidity_percent": 50.0, + "solar_irradiance_wm2": 0.0, + "is_working_hours": is_working_hours, + }, + ) + + +def insere_prediction( + connexion: Connection, + site_id: str, + *, + target_at: datetime, + predicted_value: float | None = 42.0, + model_reference: str = "lightgbm-test000000", + status: str = "available", + failure_reason: str | None = None, +) -> None: + connexion.execute( + _INSERT_PREDICTION, + { + "site_id": site_id, + "target_at": target_at, + "predicted_value": predicted_value, + "model_reference": model_reference, + "status": status, + "failure_reason": failure_reason, + }, + ) + + +@pytest.fixture(scope="session") +def url_ml() -> URL: + valeur = os.environ.get("ML_DATABASE_URL") + if not valeur: + pytest.fail("ML_DATABASE_URL absente. Voir `make ml-test-integration`.") + + url = make_url(valeur) + if url.database != BASE_ATTENDUE: + pytest.fail( + f"Ces tests ecrivent et suppriment : ML_DATABASE_URL doit viser {BASE_ATTENDUE}, " + f"pas {url.database}." + ) + return url + + +@pytest.fixture(scope="session") +def moteur_ml(url_ml: URL) -> Iterator[Engine]: + moteur = create_engine(url_ml) + try: + yield moteur + finally: + moteur.dispose() + + +@pytest.fixture +def connexion_ml(moteur_ml: Engine) -> Iterator[Connection]: + with moteur_ml.connect() as connexion: + transaction = connexion.begin() + try: + yield connexion + finally: + transaction.rollback() + + +@dataclass +class Parc: + """Semis valide en base, et son nettoyage, pour les tests qui appellent `run_scoring`. + + Chaque `site_id` porte une marque unique : la base de test est partagee entre un run local + et la CI. + """ + + moteur: Engine + sites: list[str] = field(default_factory=list) + + def site(self, *, site_type: str = SITE_TYPE, capacity_kw: float | None = CAPACITY_KW) -> str: + with self.moteur.begin() as connexion: + site_id = insere_site(connexion, site_type=site_type, capacity_kw=capacity_kw) + self.sites.append(site_id) + return site_id + + def lectures(self, site_id: str, **arguments: Any) -> list[datetime]: + with self.moteur.begin() as connexion: + return insere_lectures(connexion, site_id, **arguments) + + def lecture(self, site_id: str, **arguments: Any) -> None: + with self.moteur.begin() as connexion: + insere_lecture(connexion, site_id, **arguments) + + def prediction(self, site_id: str, **arguments: Any) -> None: + with self.moteur.begin() as connexion: + insere_prediction(connexion, site_id, **arguments) + + def predictions_ecrites(self, site_id: str) -> list[Row[Any]]: + with self.moteur.connect() as connexion: + return list(connexion.execute(_SELECT_PREDICTIONS, {"site_id": site_id})) + + def nettoie(self) -> None: + if not self.sites: + return + + with self.moteur.begin() as connexion: + for suppression in _SUPPRESSIONS: + connexion.execute(suppression, {"sites": self.sites}) + + +@pytest.fixture +def parc(moteur_ml: Engine) -> Iterator[Parc]: + semis = Parc(moteur=moteur_ml) + try: + yield semis + finally: + semis.nettoie() + + +def trame_synthetique(*, sites: int = 2, heures: int = 400) -> pd.DataFrame: + """Lectures horaires deterministes, assez longues pour que le lag de 168 h existe.""" + depart = datetime(2024, 1, 1, tzinfo=UTC) + morceaux = [ + pd.DataFrame( + { + "site_id": f"SITE{numero:03d}", + "timestamp": [depart + timedelta(hours=rang) for rang in range(heures)], + TARGET_COLUMN: [ + 50.0 + 10.0 * math.sin(rang / 12.0) + numero * 5.0 for rang in range(heures) + ], + "temperature_celsius": 15.0, + "humidity_percent": 50.0, + "solar_irradiance_wm2": 0.0, + "is_working_hours": True, + "site_type": SITE_TYPE, + "capacity_kw": CAPACITY_KW, + } + ) + for numero in range(sites) + ] + return pd.concat(morceaux, ignore_index=True) + + +@pytest.fixture(scope="session") +def modele_jetable(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Booster reel entraine sur une trame synthetique, ecrit dans un repertoire temporaire. + + Ni `ml/models/` (ignore par git, et le polluer serait un effet de bord), ni + `enervision_ml.train.train()` (qui journalise dans MLflow sans garde). Le typage `category` + de `site_type` reproduit celui de l'entrainement : c'est le `pandas_categorical` enregistre + dans le modele que `score()` devra retrouver. + """ + features = build_features(trame_synthetique()).dropna(subset=feature_columns()) + typee = features.copy() + typee["site_type"] = typee["site_type"].astype("category") + + donnees = lgb.Dataset( + typee[feature_columns()], + label=typee[TARGET_COLUMN], + categorical_feature=["site_type"], + ) + booster = lgb.train( + {"objective": "regression", "num_leaves": 7, "min_data_in_leaf": 5, "verbosity": -1}, + donnees, + num_boost_round=5, + ) + + chemin = tmp_path_factory.mktemp("modele") / "lightgbm-consumption.txt" + booster.save_model(str(chemin)) + return chemin diff --git a/ml/tests/test_data.py b/ml/tests/test_data.py index 0aa42a1..3494d4e 100644 --- a/ml/tests/test_data.py +++ b/ml/tests/test_data.py @@ -1,6 +1,7 @@ from pathlib import Path import pandas as pd +import pytest from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv @@ -53,3 +54,44 @@ def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None assert frame["capacity_kw"].dtype == "float64" assert pd.isna(frame["capacity_kw"].iloc[0]) + + +@pytest.mark.parametrize("present", ["1", "True"], ids=["entier", "booleen_textuel"]) +def test_load_from_csv_keeps_a_missing_is_working_hours_as_nan( + tmp_path: Path, present: str +) -> None: + # Une case vide vaut "on ne sait pas", que LightGBM sait traiter. La rendre `True` inventerait + # une heure ouvree, et le modele apprendrait sur une valeur que personne n'a mesuree. + csv_path = write_csv( + tmp_path, + f"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,{present},office", + "SITE001,2026-01-01T01:00:00,11.5,15.2,50.5,0.0,,office", + ) + + frame = load_from_csv(csv_path) + + assert frame["is_working_hours"].iloc[0] == 1 + assert pd.isna(frame["is_working_hours"].iloc[1]) + + +@pytest.mark.parametrize( + "valeurs", + [("1", "0"), ("True", "False")], + ids=["entier", "booleen_textuel"], +) +def test_load_from_csv_always_types_is_working_hours_as_float( + tmp_path: Path, valeurs: tuple[str, str] +) -> None: + # Le dtype ne doit pas dependre de l'ecriture du fichier ni de la presence d'un trou : c'est + # ce qui rend comparable le schema des deux chargeurs, cf. `test_data_integration.py`. + present, absent = valeurs + csv_path = write_csv( + tmp_path, + f"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,{present},office", + f"SITE001,2026-01-01T01:00:00,11.5,15.2,50.5,0.0,{absent},office", + ) + + frame = load_from_csv(csv_path) + + assert frame["is_working_hours"].dtype == "float64" + assert list(frame["is_working_hours"]) == [1.0, 0.0] diff --git a/ml/tests/test_data_integration.py b/ml/tests/test_data_integration.py new file mode 100644 index 0000000..3b5ea1a --- /dev/null +++ b/ml/tests/test_data_integration.py @@ -0,0 +1,198 @@ +from datetime import timedelta +from pathlib import Path + +import pandas as pd +import pytest +from sqlalchemy import Connection + +from enervision_ml.data import ( + OUTPUT_COLUMNS, + load_from_csv, + load_from_database, + load_recent_from_database, +) +from tests.conftest import ANCRAGE, insere_lecture, insere_lectures, insere_site + +pytestmark = pytest.mark.integration + + +def test_load_from_database_returns_the_nine_contract_columns(connexion_ml: Connection) -> None: + site_id = insere_site(connexion_ml) + insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE) + + frame = load_from_database(connexion_ml) + + assert list(frame.columns) == OUTPUT_COLUMNS + + +def test_load_from_database_joins_the_site_attributes_to_every_reading( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml, site_type="factory", capacity_kw=250.0) + insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE) + + frame = load_from_database(connexion_ml) + + mien = frame[frame["site_id"] == site_id] + assert len(mien) == 3 + assert set(mien["site_type"]) == {"factory"} + assert set(mien["capacity_kw"]) == {250.0} + + +def test_load_recent_from_database_excludes_readings_before_the_since_bound( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + insere_lectures(connexion_ml, site_id, heures=5, fin=ANCRAGE) + + frame = load_recent_from_database( + connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE + ) + + assert list(frame["timestamp"]) == [ + ANCRAGE - timedelta(hours=2), + ANCRAGE - timedelta(hours=1), + ANCRAGE, + ] + + +def test_load_recent_from_database_includes_a_reading_exactly_at_the_since_bound( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + insere_lecture(connexion_ml, site_id, instant=ANCRAGE) + + frame = load_recent_from_database( + connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3) + ) + + assert len(frame) == 1 + + +def test_load_recent_from_database_keeps_timestamps_timezone_aware( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + insere_lecture(connexion_ml, site_id, instant=ANCRAGE) + + frame = load_recent_from_database( + connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3) + ) + + assert frame["timestamp"].dt.tz is not None + + +def test_load_recent_from_database_orders_readings_by_site_then_timestamp( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + for decalage in (2, 0, 1): + insere_lecture(connexion_ml, site_id, instant=ANCRAGE + timedelta(hours=decalage)) + + frame = load_recent_from_database( + connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3) + ) + + assert list(frame["timestamp"]) == [ + ANCRAGE, + ANCRAGE + timedelta(hours=1), + ANCRAGE + timedelta(hours=2), + ] + + +def test_load_recent_from_database_returns_the_contract_columns_even_without_any_row( + connexion_ml: Connection, +) -> None: + frame = load_recent_from_database( + connexion_ml, since=ANCRAGE + timedelta(days=365), until=ANCRAGE + timedelta(days=400) + ) + + assert frame.empty + assert list(frame.columns) == OUTPUT_COLUMNS + + +def test_load_recent_from_database_types_a_fully_null_capacity_kw_as_float64( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml, capacity_kw=None) + insere_lectures(connexion_ml, site_id, heures=3, fin=ANCRAGE) + + frame = load_recent_from_database( + connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE + ) + + assert frame["capacity_kw"].dtype == "float64" + assert frame["capacity_kw"].isna().all() + + +def test_load_recent_from_database_types_a_null_is_working_hours_as_float64( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + insere_lecture(connexion_ml, site_id, instant=ANCRAGE, is_working_hours=None) + insere_lecture( + connexion_ml, site_id, instant=ANCRAGE + timedelta(hours=1), is_working_hours=True + ) + + frame = load_recent_from_database( + connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3) + ) + + assert frame["is_working_hours"].dtype == "float64" + assert list(frame["is_working_hours"].isna()) == [True, False] + + +def test_load_recent_from_database_types_is_working_hours_as_float64_even_without_a_null( + connexion_ml: Connection, +) -> None: + # Sans cette garantie, le dtype dependrait du contenu de la fenetre lue : `bool` ici, `float64` + # des qu'une seule lecture est a NULL, et le schema des deux chargeurs cesserait d'etre egal. + site_id = insere_site(connexion_ml) + insere_lectures(connexion_ml, site_id, heures=2, fin=ANCRAGE) + + frame = load_recent_from_database( + connexion_ml, since=ANCRAGE - timedelta(hours=2), until=ANCRAGE + ) + + assert frame["is_working_hours"].dtype == "float64" + + +def test_both_loaders_produce_the_same_columns_in_the_same_order( + connexion_ml: Connection, tmp_path: Path +) -> None: + site_id = insere_site(connexion_ml) + insere_lectures(connexion_ml, site_id, heures=2, fin=ANCRAGE) + csv_path = tmp_path / "lectures.csv" + pd.DataFrame( + { + "site_id": [site_id], + "timestamp": [ANCRAGE], + "consumption_kwh": [50.0], + "temperature_celsius": [15.0], + "humidity_percent": [50.0], + "solar_irradiance_wm2": [0.0], + "is_working_hours": [True], + "site_type": ["office"], + } + ).to_csv(csv_path, index=False) + + depuis_la_base = load_recent_from_database( + connexion_ml, since=ANCRAGE - timedelta(hours=1), until=ANCRAGE + ) + depuis_le_csv = load_from_csv(csv_path) + + assert list(depuis_la_base.columns) == list(depuis_le_csv.columns) + assert depuis_la_base.dtypes.to_dict() == depuis_le_csv.dtypes.to_dict() + + +def test_load_recent_from_database_excludes_readings_after_the_until_bound( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + insere_lectures(connexion_ml, site_id, heures=5, fin=ANCRAGE + timedelta(hours=4)) + + frame = load_recent_from_database( + connexion_ml, since=ANCRAGE - timedelta(days=1), until=ANCRAGE + ) + + assert list(frame["timestamp"]) == [ANCRAGE] diff --git a/ml/tests/test_score.py b/ml/tests/test_score.py index fb3e015..3ce670a 100644 --- a/ml/tests/test_score.py +++ b/ml/tests/test_score.py @@ -275,3 +275,24 @@ def test_run_scoring_in_csv_mode_scores_without_touching_a_database(tmp_path: Pa assert {r.site_id for r in resultats} == {"site-a", "site-b"} assert all(r.status == "available" for r in resultats) assert all(r.predicted_value == 7.0 for r in resultats) + + +def test_run_scoring_in_csv_mode_targets_the_hour_after_the_reference_instant( + tmp_path: Path, +) -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + frame = make_recent("site-a", heures=400, depart=depart) + csv_path = tmp_path / "recent.csv" + frame.to_csv(csv_path, index=False) + model_path = tmp_path / "model.txt" + model_path.write_bytes(b"peu importe le contenu pour ce test") + rattrapage = depart + timedelta(hours=300) + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "enervision_ml.score.lgb.Booster", lambda model_file: FakeBooster(valeur=7.0) + ) + + resultats = run_scoring(model_path=model_path, csv_path=csv_path, now=rattrapage) + + assert [r.target_at for r in resultats] == [rattrapage + timedelta(hours=1)] diff --git a/ml/tests/test_score_integration.py b/ml/tests/test_score_integration.py new file mode 100644 index 0000000..aecee67 --- /dev/null +++ b/ml/tests/test_score_integration.py @@ -0,0 +1,238 @@ +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import Connection, Row, text +from sqlalchemy.exc import IntegrityError + +from enervision_ml.score import ( + INSUFFICIENT_DATA_REASON, + LOOKBACK, + MAX_STALENESS, + ScoredSite, + model_reference, + run_scoring, + write_predictions, +) +from tests.conftest import ANCRAGE, Parc, insere_site + +pytestmark = pytest.mark.integration + +REFERENCE = "lightgbm-000000000000" + +_SELECT = text( + """ + SELECT target_at, target_metric, period_minutes, predicted_value, + model_reference, status, failure_reason + FROM prediction + WHERE site_id = :site_id + ORDER BY prediction_id + """ +) + + +def lignes(connexion: Connection, site_id: str) -> list[Row[Any]]: + return list(connexion.execute(_SELECT, {"site_id": site_id})) + + +def disponible( + site_id: str, + *, + target_at: datetime = ANCRAGE, + predicted_value: float | None = 12.5, +) -> ScoredSite: + return ScoredSite( + site_id=site_id, + target_at=target_at, + status="available", + predicted_value=predicted_value, + failure_reason=None, + ) + + +def test_write_predictions_inserts_one_row_per_scored_site(connexion_ml: Connection) -> None: + premier = insere_site(connexion_ml) + second = insere_site(connexion_ml) + + write_predictions(connexion_ml, [disponible(premier), disponible(second)], reference=REFERENCE) + + assert len(lignes(connexion_ml, premier)) == 1 + assert len(lignes(connexion_ml, second)) == 1 + + +def test_write_predictions_stores_the_model_reference_and_the_hourly_period( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + + write_predictions(connexion_ml, [disponible(site_id)], reference=REFERENCE) + + ligne = lignes(connexion_ml, site_id)[0] + assert ligne.model_reference == REFERENCE + assert ligne.target_metric == "consumption_kwh" + assert ligne.period_minutes == 60 + + +def test_write_predictions_stacks_a_second_run_instead_of_overwriting_the_first( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + + write_predictions( + connexion_ml, [disponible(site_id, predicted_value=10.0)], reference=REFERENCE + ) + write_predictions( + connexion_ml, [disponible(site_id, predicted_value=20.0)], reference=REFERENCE + ) + + assert [ligne.predicted_value for ligne in lignes(connexion_ml, site_id)] == [10.0, 20.0] + + +def test_write_predictions_writes_nothing_when_no_site_was_scored( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + + write_predictions(connexion_ml, [], reference=REFERENCE) + + assert lignes(connexion_ml, site_id) == [] + + +def test_write_predictions_rejects_an_available_row_without_a_predicted_value( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + + with pytest.raises(IntegrityError, match="ck_prediction_status"): + write_predictions( + connexion_ml, [disponible(site_id, predicted_value=None)], reference=REFERENCE + ) + + +def test_write_predictions_rejects_an_insufficient_data_row_carrying_a_value( + connexion_ml: Connection, +) -> None: + site_id = insere_site(connexion_ml) + incoherent = ScoredSite( + site_id=site_id, + target_at=ANCRAGE, + status="insufficient_data", + predicted_value=12.5, + failure_reason=INSUFFICIENT_DATA_REASON, + ) + + with pytest.raises(IntegrityError, match="ck_prediction_status"): + write_predictions(connexion_ml, [incoherent], reference=REFERENCE) + + +def test_write_predictions_rejects_a_prediction_for_an_unknown_site( + connexion_ml: Connection, +) -> None: + with pytest.raises(IntegrityError, match="fk_prediction_site"): + write_predictions(connexion_ml, [disponible("SITE-INCONNU")], reference=REFERENCE) + + +def test_run_scoring_writes_an_available_prediction_for_a_site_with_a_full_week( + parc: Parc, modele_jetable: Path +) -> None: + site_id = parc.site() + parc.lectures(site_id, heures=200, fin=ANCRAGE) + + run_scoring(model_path=modele_jetable, now=ANCRAGE) + + ligne = parc.predictions_ecrites(site_id)[0] + assert ligne.status == "available" + assert ligne.predicted_value is not None + assert ligne.target_at == ANCRAGE + timedelta(hours=1) + + +def test_run_scoring_writes_insufficient_data_when_the_weekly_lag_is_missing( + parc: Parc, modele_jetable: Path +) -> None: + site_id = parc.site() + parc.lectures(site_id, heures=100, fin=ANCRAGE) + + run_scoring(model_path=modele_jetable, now=ANCRAGE) + + ligne = parc.predictions_ecrites(site_id)[0] + assert ligne.status == "insufficient_data" + assert ligne.predicted_value is None + assert ligne.failure_reason == INSUFFICIENT_DATA_REASON + + +def test_run_scoring_writes_a_staleness_reason_when_the_last_reading_is_too_old( + parc: Parc, modele_jetable: Path +) -> None: + site_id = parc.site() + parc.lectures(site_id, heures=200, fin=ANCRAGE) + + run_scoring(model_path=modele_jetable, now=ANCRAGE + MAX_STALENESS + timedelta(hours=1)) + + ligne = parc.predictions_ecrites(site_id)[0] + assert ligne.status == "insufficient_data" + assert ligne.failure_reason != INSUFFICIENT_DATA_REASON + + +def test_run_scoring_writes_nothing_when_every_reading_is_older_than_the_window( + parc: Parc, modele_jetable: Path +) -> None: + site_id = parc.site() + parc.lectures(site_id, heures=200, fin=ANCRAGE) + + run_scoring(model_path=modele_jetable, now=ANCRAGE + LOOKBACK + timedelta(days=1)) + + assert parc.predictions_ecrites(site_id) == [] + + +def test_run_scoring_only_writes_the_site_that_was_requested( + parc: Parc, modele_jetable: Path +) -> None: + demande = parc.site() + ignore = parc.site() + parc.lectures(demande, heures=200, fin=ANCRAGE) + parc.lectures(ignore, heures=200, fin=ANCRAGE) + + run_scoring(model_path=modele_jetable, site_id=demande, now=ANCRAGE) + + assert len(parc.predictions_ecrites(demande)) == 1 + assert parc.predictions_ecrites(ignore) == [] + + +def test_run_scoring_uses_the_model_file_hash_as_model_reference( + parc: Parc, modele_jetable: Path +) -> None: + site_id = parc.site() + parc.lectures(site_id, heures=200, fin=ANCRAGE) + + run_scoring(model_path=modele_jetable, now=ANCRAGE) + + ligne = parc.predictions_ecrites(site_id)[0] + assert ligne.model_reference == model_reference(modele_jetable) + + +def test_run_scoring_appends_a_second_row_when_it_runs_twice( + parc: Parc, modele_jetable: Path +) -> None: + site_id = parc.site() + parc.lectures(site_id, heures=200, fin=ANCRAGE) + + run_scoring(model_path=modele_jetable, now=ANCRAGE) + run_scoring(model_path=modele_jetable, now=ANCRAGE) + + ecrites = parc.predictions_ecrites(site_id) + assert len(ecrites) == 2 + assert ecrites[0].target_at == ecrites[1].target_at + + +def test_run_scoring_targets_the_hour_after_the_reference_instant( + parc: Parc, modele_jetable: Path +) -> None: + site_id = parc.site() + parc.lectures(site_id, heures=200, fin=ANCRAGE + timedelta(hours=48)) + rattrapage = ANCRAGE + + run_scoring(model_path=modele_jetable, now=rattrapage) + + ligne = parc.predictions_ecrites(site_id)[0] + assert ligne.target_at == rattrapage + timedelta(hours=1) diff --git a/ml/tests/test_train.py b/ml/tests/test_train.py index 3084983..7f22d5c 100644 --- a/ml/tests/test_train.py +++ b/ml/tests/test_train.py @@ -3,6 +3,7 @@ from pathlib import Path import numpy as np import pandas as pd +import pytest from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns from enervision_ml.train import chronological_split, prepare_dataset, train @@ -74,3 +75,19 @@ def test_train_runs_end_to_end_on_synthetic_data_and_beats_a_dummy_baseline( assert model_metrics["n_observations"] > 0 assert model_metrics["mae"] >= 0 assert baseline_metrics["n_observations"] == model_metrics["n_observations"] + assert model_metrics["mae"] < baseline_metrics["mae"] + + +def test_train_raises_when_the_validation_window_is_empty(tmp_path: Path) -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + frame = make_frame("site-a", heures=50, depart=depart) # trop court pour un lag de 168h + csv_path = tmp_path / "trop_court.csv" + frame.to_csv(csv_path, index=False) + + with pytest.raises(ValueError, match="Fenetre d'entrainement ou de validation vide"): + train( + csv_path=csv_path, + model_output=tmp_path / "model.txt", + test_fraction=0.2, + tracking_uri=f"sqlite:///{tmp_path / 'mlflow.db'}", + ) diff --git a/ml/uv.lock b/ml/uv.lock index ed8e065..5a0fa9a 100644 --- a/ml/uv.lock +++ b/ml/uv.lock @@ -388,6 +388,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" }, ] +[[package]] +name = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" }, + { url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" }, + { url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" }, + { url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" }, + { url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" }, + { url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" }, + { url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" }, + { url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + [[package]] name = "cryptography" version = "50.0.1" @@ -494,6 +533,7 @@ dev = [ { name = "mypy" }, { name = "pandas-stubs" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -512,6 +552,7 @@ dev = [ { name = "mypy", specifier = ">=2.3.1" }, { name = "pandas-stubs", specifier = ">=3.0.5.260914" }, { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "ruff", specifier = ">=0.16.7" }, ] @@ -1591,6 +1632,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" diff --git a/scripts/README.md b/scripts/README.md index 9395406..848aeb4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,3 +1,11 @@ # Scripts Outillage local du monorepo. Les taches courantes passent par le `Makefile` racine. + +## dast-token.sh + +Prépare le scan DAST (`.github/workflows/dast.yml`) : sur une API déjà démarrée, crée un compte +`lecteur` jetable, lui fait passer le changement de mot de passe obligatoire et écrit son jeton +d'accès sur la sortie standard. À lancer depuis `apps/backend`, contre une base **jetable** (il y +crée deux comptes) : `BASE_URL=http://localhost:8000 ../../scripts/dast-token.sh`. Nécessite `curl`, +`jq` et `openssl`. diff --git a/scripts/dast-token.sh b/scripts/dast-token.sh new file mode 100755 index 0000000..2bbbdfb --- /dev/null +++ b/scripts/dast-token.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Prépare le scan DAST : crée un compte `lecteur` sur une API déjà démarrée, lui fait passer le +# changement de mot de passe obligatoire, et écrit son jeton d'accès sur la sortie standard. +# +# Piège : un compte neuf est en `must_change_password`, et toute route gardée le refuse tant que +# le mot de passe n'a pas été changé. Sans cette étape, ZAP ne verrait que 403 sur les routes +# gardées et le scan ne testerait rien de l'API authentifiée. +# +# Contrainte : le compte du scan est `lecteur`, jamais `admin`. Un scan actif avec un jeton admin +# frapperait POST /users ou la réinitialisation de mots de passe pour de bon. +# +# L'administrateur n'existe que pour créer ce compte (l'API n'a pas d'inscription publique). +# À lancer depuis apps/backend, dans un environnement où DATABASE_URL et APP_SECRET_KEY visent +# une base JETABLE : le script y crée deux comptes. + +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:8000}" +API="$BASE_URL/api/v1" +SUFFIXE="$(openssl rand -hex 4)" +EMAIL_ADMIN="dast-admin-$SUFFIXE@enervision.fr" +EMAIL_LECTEUR="dast-lecteur-$SUFFIXE@enervision.fr" + +# Classes exigées par le validateur : majuscule, minuscule, chiffre, caractère spécial. +nouveau_mot_de_passe() { echo "Dast-$(openssl rand -hex 12)-Aa1!"; } + +# Tout ce qui n'est pas la sortie finale part sur stderr : la sortie standard ne porte que le jeton. +journal() { echo "dast-token: $*" >&2; } + +connexion() { + local email="$1" mot_de_passe="$2" + curl -fsS -X POST "$API/auth/login" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg e "$email" --arg p "$mot_de_passe" '{email:$e, password:$p}')" \ + | jq -r '.access_token' +} + +# Rend le nouveau jeton d'accès : `/auth/password` en émet un (avec l'`iat` de la session en +# cours, cf. le piège documenté dans `app/api/deps.py`), pas seulement une confirmation. S'y fier +# évite une reconnexion, donc un second hachage Argon2id (19456 Kio) et un aller-retour de +# refresh-token superflus sur le chemin critique de la CI. +changer_mot_de_passe() { + local jeton="$1" ancien="$2" nouveau="$3" + curl -fsS -X POST "$API/auth/password" \ + -H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')" \ + | jq -r '.access_token' +} + +journal "création de l'administrateur $EMAIL_ADMIN" +if ! SORTIE="$(uv run --frozen --no-sync --no-build python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)"; then + journal "la création de l'administrateur a échoué :" + journal "$SORTIE" + exit 1 +fi +MDP_ADMIN="$(sed -n 's/^Mot de passe généré, il ne sera plus affiché : //p' <<<"$SORTIE")" +[[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie :"; journal "$SORTIE"; exit 1; } + +JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")" +NOUVEAU_ADMIN="$(nouveau_mot_de_passe)" +JETON="$(changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN")" + +journal "création du lecteur $EMAIL_LECTEUR" +REPONSE="$(curl -fsS -X POST "$API/users" -H "Authorization: Bearer $JETON" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg e "$EMAIL_LECTEUR" '{email:$e, role:"lecteur"}')")" +MDP_TEMPORAIRE="$(jq -r '.temporary_password // empty' <<<"$REPONSE")" +[[ -n "$MDP_TEMPORAIRE" ]] || { journal "mot de passe temporaire introuvable dans la réponse de POST /users :"; journal "$REPONSE"; exit 1; } + +JETON="$(connexion "$EMAIL_LECTEUR" "$MDP_TEMPORAIRE")" +NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)" +JETON="$(changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR")" + +# Vérifie que le jeton ouvre bien une route gardée avant de le rendre. +CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$API/sites" -H "Authorization: Bearer $JETON")" +[[ "$CODE" == "200" ]] || { journal "GET /sites répond $CODE avec le jeton du lecteur, attendu 200"; exit 1; } + +journal "jeton du lecteur prêt" +echo "$JETON" diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh new file mode 100755 index 0000000..6bad530 --- /dev/null +++ b/scripts/provision-host.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Pourquoi : la machine porte deux environnements, chacun un clone du dépôt, un `.env` et un +# projet Compose (ADR 0009). Ce script prépare la machine et les deux dossiers sans rien +# démarrer : construction des images et démarrage restent à l'opérateur, puis au runner GitHub. +# Rejouable : un dossier déjà cloné est réaligné sur sa branche, un `.env` existant n'est jamais +# réécrit, un certificat présent n'est jamais régénéré. + +set -euo pipefail + +DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}" +RACINE="${RACINE:-/srv/enervision}" +ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}" +PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}" +COMPOSE_MINIMALE="2.24.4" + +erreur() { echo "erreur : $*" >&2; exit 1; } +secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; } +# Clé Fernet : 32 octets en base64 urlsafe, padding compris. +fernet() { openssl rand -base64 32 | tr '+/' '-_'; } + +verifier_outils() { + for outil in git make openssl curl; do + command -v "$outil" >/dev/null || erreur "$outil absent (apt-get install $outil)" + done + command -v docker >/dev/null || erreur "Docker absent : https://docs.docker.com/engine/install/debian/" + docker info >/dev/null 2>&1 || erreur "le démon Docker ne répond pas, ou l'utilisateur n'est pas dans le groupe docker" + local version + version="$(docker compose version --short 2>/dev/null || true)" + [[ -n "$version" ]] || erreur "plugin docker compose absent (paquet docker-compose-plugin)" + [[ "$(printf '%s\n%s\n' "$COMPOSE_MINIMALE" "${version#v}" | sort -V | head -1)" == "$COMPOSE_MINIMALE" ]] \ + || erreur "docker compose $version trop ancien : $COMPOSE_MINIMALE requis pour !override et !reset" + curl -fsSI --max-time 10 https://github.com >/dev/null || erreur "pas de sortie HTTPS vers github.com" + echo "docker compose $version, sortie Internet : ok" +} + +preparer() { + local env="$1" branche="$2" hote="$3" origine="$4" + local port_https="$5" port_http="$6" port_pg="$7" port_mailpit="$8" port_airflow="$9" + local dossier="$RACINE/$env" + + if [[ -d "$dossier/.git" ]]; then + git -C "$dossier" fetch --quiet origin "$branche" + git -C "$dossier" checkout --quiet "$branche" + git -C "$dossier" reset --quiet --hard "origin/$branche" + else + git clone --quiet --branch "$branche" "$DEPOT" "$dossier" + fi + + if [[ ! -f "$dossier/.env" ]]; then + local brouillon="$dossier/.env.brouillon" oubliees + sed -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|" \ + -e "s|^POSTGRES_PORT=.*|POSTGRES_PORT=$port_pg|" \ + -e "s|^APP_SECRET_KEY=.*|APP_SECRET_KEY=$(secret)|" \ + -e "s|^MAILPIT_UI_PORT=.*|MAILPIT_UI_PORT=$port_mailpit|" \ + -e "s|^AIRFLOW_PORT=.*|AIRFLOW_PORT=$port_airflow|" \ + -e "s|^AIRFLOW_FERNET_KEY=.*|AIRFLOW_FERNET_KEY=$(fernet)|" \ + -e "s|^AIRFLOW_API_SECRET_KEY=.*|AIRFLOW_API_SECRET_KEY=$(secret)|" \ + -e "s|^AIRFLOW_JWT_SECRET=.*|AIRFLOW_JWT_SECRET=$(secret)|" \ + -e "s|^AIRFLOW_ADMIN_PASSWORD=.*|AIRFLOW_ADMIN_PASSWORD=$(secret | cut -c1-20)|" \ + -e "s|^AIRFLOW_APP_SECRET_KEY=.*|AIRFLOW_APP_SECRET_KEY=$(secret)|" \ + -e "s|^PUBLIC_HOST=.*|PUBLIC_HOST=$hote|" \ + -e "s|^PUBLIC_ORIGIN=.*|PUBLIC_ORIGIN=$origine|" \ + -e "s|^COMPOSE_PROJECT_NAME=.*|COMPOSE_PROJECT_NAME=enervision-$env|" \ + -e "s|^PROXY_HTTP_PORT=.*|PROXY_HTTP_PORT=$port_http|" \ + -e "s|^PROXY_HTTPS_PORT=.*|PROXY_HTTPS_PORT=$port_https|" \ + "$dossier/.env.example" > "$brouillon" + # Branche antérieure à l'ADR 0009 : ces clés manquent alors dans .env.example. + for cle in "COMPOSE_PROJECT_NAME=enervision-$env" "PUBLIC_ORIGIN=$origine" \ + "PROXY_HTTP_PORT=$port_http" "PROXY_HTTPS_PORT=$port_https"; do + grep -q "^${cle%%=*}=" "$brouillon" || echo "$cle" >> "$brouillon" + done + # Piège : une clé renommée en amont garde sa valeur d'exemple, que le `:?` du compose ne + # voit pas puisqu'elle n'est pas vide. Cas vécu : AIRFLOW_WEBSERVER_SECRET_KEY, Airflow 3. + oubliees="$(grep '=change_me$' "$brouillon" | grep -v '^APP_MOCK_API_' | cut -d= -f1 | tr '\n' ' ' || true)" + if [[ -n "$oubliees" ]]; then + rm -f "$brouillon" + erreur "$env : secrets non générés, .env non écrit : $oubliees" + fi + chmod 600 "$brouillon" + mv "$brouillon" "$dossier/.env" + echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD." + fi + + if [[ ! -f "$dossier/infra/proxy/tls/fullchain.pem" ]]; then + (cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh) + fi + echo "$env : $dossier sur $branche, $origine" +} + +verifier_outils +mkdir -p "$RACINE" + +# env branche hôte origine https http pg mailpit airflow +preparer prod main enervision.local https://enervision.local 443 80 5433 8025 8080 +preparer rec dev rec.enervision.local https://rec.enervision.local:8443 8443 127.0.0.1:8081 5434 8026 8082 + +if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then + chown -R "$PROPRIETAIRE" "$RACINE" +fi + +cat < +Le runner GitHub Actions (label eni-g3) rejouera le déploiement à chaque push sur dev et main. +L'installer sous le propriétaire de $RACINE, sinon git refuse ces dépôts et le .env en 600 lui +échappe : relancer au besoin ce script avec PROPRIETAIRE=. +Données historiques : git ne porte pas data/raw, déposer les fichiers dans chaque dossier avant +de déclencher le DAG historical_import. +Depuis un poste : ajouter « $ADRESSE enervision.local rec.enervision.local » à /etc/hosts. +FIN diff --git a/sonar-project.properties b/sonar-project.properties index 49c6abe..8f9ebed 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,15 +3,17 @@ sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 # Dossier contenant le code source -sonar.sources=apps/frontend/src,apps/backend +sonar.sources=apps/frontend/src,apps/backend,ml,etl/airflow # Dossier contenant les tests -sonar.tests=apps/frontend/src,apps/backend/tests +sonar.tests=apps/frontend/src,apps/backend/tests,ml/tests,etl/airflow/tests sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py # Liste des fichiers et dossiers à exclure de l'analyse -sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts +sonar.exclusions=.pytest_cache,.venv,.airflow_home,alembic,tests,ml/data/**,ml/models/**,ml/mlruns/**,ml/mlartifacts/**,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts # Chemin vers le rapport de couverture de code # Fichier généré par Pytest -sonar.python.coverage.reportPaths=apps/backend/coverage.xml +sonar.python.coverage.reportPaths=apps/backend/coverage.xml,ml/coverage.xml +# Les DAGs n'ont pas de couverture mesurable : leurs tests ne font que les charger (DagBag) +sonar.coverage.exclusions=etl/airflow/** sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info